fix(axum,actix): link a route to a handler written below its call (#2326) (#2359)

With rustfmt's wrapped form

    .route(
        "/multi",
        get(handlers::multi_line_handler),
    )

the route was never linked to `multi_line_handler`, even when it was the
only function of that name; the single-line form worked. Rustfmt wraps
every `.route(` past the line width, so most routes of a real router were
affected (31 of 364 handler references resolved in the reporter's router).

Cause: the Axum block of the Rust framework extractor recorded the handler
reference at the line of `.route(`, column 0. Every resolved reference then
passes the Rust scope gate (isRustNameInScope), which reads that one line
to see how the name is written (`handlers::f` names the `handlers` module).
On a line holding only `.route(` it found nothing and dropped the edge. The
same position also broke single-line routes whose handler name appears
elsewhere on the line: in `.route("/login", post(handlers::user::login))`
or `delete(handlers::user::delete)` the gate read the path string or the
method router as a bare use of the name. Actix's `web::resource(..)` chains
and App-level `.route("/p", web::get().to(h))` had the same weakness.

Fix (src/resolution/frameworks/rust.ts):
- The handler reference carries the line and column of the handler's own
  name (its last path segment), for Axum and the three Actix forms; the
  route node stays on the `.route(` / `web::resource(` line. The gate is
  unchanged and still reads one line, the one the handler is written on.
- Axum's method routers are the top-level chain of `.route(`'s second
  argument: a `get(` nested inside it is a call in a closure handler's body
  (`post(|h| async move { h.get(CONTENT_LENGTH) })`), not a route. With the
  reference moved, such a call would otherwise gain an edge.
- An Actix resource's method chain also ends at the `)` of the call it is
  an argument of, so in `.service(web::resource("/").to(index))
  .route("/count", web::get().to(get_count))` the resource keeps
  `.to(index)` instead of taking `/count`'s handler.

Adopted from #2329 by @danusha2345 (the fix and the tests are theirs),
without its EXTRACTION_VERSION bump (already 28 on main); added a case for
a single-line route whose handler name the line also writes elsewhere.

Verification: __tests__/rust-route-handler-line.test.ts: 10 of 13 fail on
main in both wasm and kernel modes; 13/13 pass with the fix in both modes.
The issue's repro now prints `GET /multi [references] src/main.rs:7`.

Validation (main -> branch, route nodes and route->handler edges keyed by
route name/file/line and target QN/file):
- rust-lang/crates.io: 1 -> 5 linked (+4 wrapped routes).
- atuinsh/atuin server: 7 -> 12; +5 newly linked and 2 wrong targets
  replaced (`GET /api/v0/me` user::get -> v0::me::get, `GET
  /api/v0/record/next` a TUI `Events::next` method -> v0::record::next).
- launchbadge/realworld-axum-sqlx: 18 -> 19 (+1 wrapped chain link); 11
  same-file edges now carry the handler's line.
- tokio-rs/axum (whole repo): 204 -> 204, 19 edges carry the handler's
  line; 2 bogus `GET /` route nodes from `headers.get(..)` in closure
  handlers in routing tests are gone.
- actix/examples: 116 -> 116; `ANY / -> index` x2 replaces two wrong `GET /`
  edges (websockets/chat took the next routes' handlers; basics/hello-world
  now records its real `.service(web::resource("/").to(index))`).
No other edge changed in any of the five repos.

Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-05 22:00:29 +00:00
committed by GitHub
parent ed1a89fb0f
commit 023fc3111b
3 changed files with 247 additions and 9 deletions
+38 -9
View File
@@ -107,6 +107,13 @@ export const rustResolver: FrameworkResolver = {
const references: UnresolvedRef[] = [];
const now = Date.now();
const safe = stripCommentsForRegex(content, 'rust');
// Where a handler's own name is written, for the handler expression `expr`
// ending at `exprEnd`: the Rust scope gate reads the reference's line, and
// rustfmt wraps a long `.route(` call so its handler sits below it (#2326).
const handlerSite = (exprEnd: number, expr: string, handler: string) => {
const offset = exprEnd - expr.length + expr.lastIndexOf(handler);
return { line: safe.slice(0, offset).split('\n').length, column: offset - safe.lastIndexOf('\n', offset - 1) - 1 };
};
// Actix-web / Rocket attribute: #[get("/path")] fn handler(..)
// Capture the method, path, and the fn identifier that follows.
@@ -165,12 +172,24 @@ export const rustResolver: FrameworkResolver = {
const line = safe.slice(0, match.index).split('\n').length;
const methodBody = args.slice(pathMatch[0].length);
const bodyAt = openIdx + 1 + pathMatch[0].length;
const methodHandlerRegex = /\b(get|post|put|patch|delete|head|options|trace)\s*\(\s*([A-Za-z_][\w:]*)/g;
let mh: RegExpExecArray | null;
// The method routers are the argument's top-level chain (`get(a).post(b)`);
// a `get(` nested in it — `get(|| async { cache.get(key) })` — is a call
// inside a closure handler, not a route.
let depth = 0;
let scanned = 0;
while ((mh = methodHandlerRegex.exec(methodBody)) !== null) {
for (; scanned < mh.index; scanned++) {
if (methodBody[scanned] === '(') depth++;
else if (methodBody[scanned] === ')') depth--;
}
if (depth > 0) continue;
const upper = mh[1]!.toUpperCase();
const handler = mh[2]!.split('::').filter(Boolean).pop();
if (!handler) continue;
const site = handlerSite(bodyAt + mh.index + mh[0].length, mh[2]!, handler);
const routeNode: Node = {
id: `route:${filePath}:${line}:${upper}:${routePath}`,
@@ -191,8 +210,8 @@ export const rustResolver: FrameworkResolver = {
fromNodeId: routeNode.id,
referenceName: handler,
referenceKind: 'references',
line,
column: 0,
line: site.line,
column: site.column,
filePath,
language: 'rust',
});
@@ -201,9 +220,10 @@ export const rustResolver: FrameworkResolver = {
// Actix-web builder API (the dominant actix routing style; attribute macros
// are handled above). The handler lives in `.to(handler)`, not `get(handler)`.
const pushActixRoute = (routePath: string, method: string, handlerExpr: string, line: number) => {
const pushActixRoute = (routePath: string, method: string, handlerExpr: string, line: number, exprEnd: number) => {
const handler = handlerExpr.split('::').filter(Boolean).pop();
if (!handler) return;
const site = handlerSite(exprEnd, handlerExpr, handler);
const upper = method.toUpperCase();
const routeNode: Node = {
id: `route:${filePath}:${line}:${upper}:${routePath}`,
@@ -223,8 +243,8 @@ export const rustResolver: FrameworkResolver = {
fromNodeId: routeNode.id,
referenceName: handler,
referenceKind: 'references',
line,
column: 0,
line: site.line,
column: site.column,
filePath,
language: 'rust',
});
@@ -238,7 +258,16 @@ export const rustResolver: FrameworkResolver = {
const after = match.index + match[0].length;
// Bound the resource's method chain at the next resource() to avoid bleed.
const nextRes = safe.indexOf('web::resource', after);
const end = Math.min(after + 500, nextRes === -1 ? safe.length : nextRes);
let end = Math.min(after + 500, nextRes === -1 ? safe.length : nextRes);
// ...and at the `)` of the call it is an argument of: in
// `.service(web::resource("/a").to(a)).route("/b", web::get().to(b))`, `b` is not `/a`'s.
for (let i = after, depth = 0; i < end; i++) {
if (safe[i] === '(') depth++;
else if (safe[i] === ')' && --depth < 0) {
end = i;
break;
}
}
const chain = safe.slice(after, end);
const methodTo = /web::(get|post|put|patch|delete|head)\s*\(\s*\)\s*\.to\s*\(\s*([A-Za-z_][\w:]*)/g;
@@ -246,13 +275,13 @@ export const rustResolver: FrameworkResolver = {
let found = false;
while ((m2 = methodTo.exec(chain)) !== null) {
const mLine = startLine + chain.slice(0, m2.index).split('\n').length - 1;
pushActixRoute(routePath, m2[1]!, m2[2]!, mLine);
pushActixRoute(routePath, m2[1]!, m2[2]!, mLine, after + m2.index + m2[0].length);
found = true;
}
// Direct `.resource("/x").to(handler)` (all methods) when no explicit verb route.
if (!found) {
const direct = chain.match(/^\s*\.to\s*\(\s*([A-Za-z_][\w:]*)/);
if (direct) pushActixRoute(routePath, 'ANY', direct[1]!, startLine);
if (direct) pushActixRoute(routePath, 'ANY', direct[1]!, startLine, after + direct[0].length);
}
}
@@ -260,7 +289,7 @@ export const rustResolver: FrameworkResolver = {
const appRouteRegex = /\.route\s*\(\s*"([^"]+)"\s*,\s*web::(get|post|put|patch|delete|head)\s*\(\s*\)\s*\.to\s*\(\s*([A-Za-z_][\w:]*)/g;
while ((match = appRouteRegex.exec(safe)) !== null) {
const line = safe.slice(0, match.index).split('\n').length;
pushActixRoute(match[1]!, match[2]!, match[3]!, line);
pushActixRoute(match[1]!, match[2]!, match[3]!, line, match.index + match[0].length);
}
return { nodes, references };