Pārlūkot izejas kodu

feat(resolution): Axum chained methods + namespaced handlers

The Axum route extractor used a flat regex that captured only the first
method(handler) of a .route() call and only a bare \w+ handler, so two
dominant Axum idioms broke:
- method chains: .route("/user", get(get_current_user).put(update_user))
  emitted no node for the .put arm — half the API was missing.
- namespaced handlers: get(listing::feed_articles) captured `listing`
  (the module), so the route resolved to nothing.

Rewrite with a balanced-paren scan of each .route(...) call, a route node
per chained method, and last-::-segment handler names. realworld-axum
12→19 routes, 19/19 resolved (every chained PUT/DELETE/POST now present,
feed_articles resolves). Rocket needed nothing (550/556, 99%, attribute
macros); crates.io confirms namespaced axum handlers resolve.

Residual frontier: actix runtime routing web::get().to(handler) (the
dominant actix style, unextracted; attribute macros 35/51). Fix is
Axum-scoped — the attribute/actix/Rocket path is untouched. Tests: chained
methods + multi-line namespaced handler. Full suite green (789 passed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 mēnesi atpakaļ
vecāks
revīzija
a7cf3fa915
2 mainītis faili ar 83 papildinājumiem un 28 dzēšanām
  1. 23 0
      __tests__/frameworks.test.ts
  2. 60 28
      src/resolution/frameworks/rust.ts

+ 23 - 0
__tests__/frameworks.test.ts

@@ -590,6 +590,29 @@ describe('rustResolver.extract', () => {
     expect(nodes[0].name).toBe('GET /users');
     expect(nodes[0].name).toBe('GET /users');
     expect(references[0].referenceName).toBe('list_users');
     expect(references[0].referenceName).toBe('list_users');
   });
   });
+
+  it('extracts every method from a chained axum .route (get().put())', () => {
+    const src = `let app = Router::new().route("/user", get(get_current_user).put(update_user));\n`;
+    const { nodes, references } = rustResolver.extract!('main.rs', src);
+    expect(nodes.map((n) => n.name)).toEqual(['GET /user', 'PUT /user']);
+    expect(references.map((r) => r.referenceName)).toEqual([
+      'get_current_user',
+      'update_user',
+    ]);
+  });
+
+  it('extracts a multi-line axum .route with a namespaced handler', () => {
+    const src = `
+let app = Router::new()
+    .route(
+        "/articles/feed",
+        get(listing::feed_articles),
+    );
+`;
+    const { nodes, references } = rustResolver.extract!('main.rs', src);
+    expect(nodes[0].name).toBe('GET /articles/feed');
+    expect(references[0].referenceName).toBe('feed_articles');
+  });
 });
 });
 
 
 describe('rustResolver.resolve cargo workspace crates', () => {
 describe('rustResolver.resolve cargo workspace crates', () => {

+ 60 - 28
src/resolution/frameworks/rust.ts

@@ -135,37 +135,56 @@ export const rustResolver: FrameworkResolver = {
       }
       }
     }
     }
 
 
-    // Axum: .route("/path", get(handler))
-    const axumRegex = /\.route\s*\(\s*"([^"]+)"\s*,\s*(get|post|put|patch|delete)\s*\(\s*(\w+)/g;
-    while ((match = axumRegex.exec(safe)) !== null) {
-      const [, routePath, method, handler] = match;
+    // Axum: .route("/path", get(h1).post(h2)…) — balanced-paren scan the route
+    // call, then emit one route node per chained method. Handlers may be
+    // namespaced (`get(module::handler)`, `get(self::list)`); take the last
+    // path segment so the ref names the fn, not the module.
+    const routeOpenRegex = /\.route\s*\(/g;
+    while ((match = routeOpenRegex.exec(safe)) !== null) {
+      const openIdx = safe.indexOf('(', match.index);
+      if (openIdx < 0) continue;
+      const closeIdx = findMatchingParen(safe, openIdx);
+      if (closeIdx < 0) continue;
+
+      const args = safe.slice(openIdx + 1, closeIdx);
+      const pathMatch = args.match(/^\s*"([^"]+)"\s*,/);
+      if (!pathMatch) continue;
+      const routePath = pathMatch[1]!;
       const line = safe.slice(0, match.index).split('\n').length;
       const line = safe.slice(0, match.index).split('\n').length;
-      const upper = method!.toUpperCase();
 
 
-      const routeNode: Node = {
-        id: `route:${filePath}:${line}:${upper}:${routePath}`,
-        kind: 'route',
-        name: `${upper} ${routePath}`,
-        qualifiedName: `${filePath}::route:${routePath}`,
-        filePath,
-        startLine: line,
-        endLine: line,
-        startColumn: 0,
-        endColumn: match[0].length,
-        language: 'rust',
-        updatedAt: now,
-      };
-      nodes.push(routeNode);
+      const methodBody = args.slice(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;
+      while ((mh = methodHandlerRegex.exec(methodBody)) !== null) {
+        const upper = mh[1]!.toUpperCase();
+        const handler = mh[2]!.split('::').filter(Boolean).pop();
+        if (!handler) continue;
 
 
-      references.push({
-        fromNodeId: routeNode.id,
-        referenceName: handler!,
-        referenceKind: 'references',
-        line,
-        column: 0,
-        filePath,
-        language: 'rust',
-      });
+        const routeNode: Node = {
+          id: `route:${filePath}:${line}:${upper}:${routePath}`,
+          kind: 'route',
+          name: `${upper} ${routePath}`,
+          qualifiedName: `${filePath}::route:${routePath}`,
+          filePath,
+          startLine: line,
+          endLine: line,
+          startColumn: 0,
+          endColumn: 0,
+          language: 'rust',
+          updatedAt: now,
+        };
+        nodes.push(routeNode);
+
+        references.push({
+          fromNodeId: routeNode.id,
+          referenceName: handler,
+          referenceKind: 'references',
+          line,
+          column: 0,
+          filePath,
+          language: 'rust',
+        });
+      }
     }
     }
 
 
     return { nodes, references };
     return { nodes, references };
@@ -181,6 +200,19 @@ const FUNCTION_KINDS = new Set(['function']);
 const SERVICE_KINDS = new Set(['struct', 'trait']);
 const SERVICE_KINDS = new Set(['struct', 'trait']);
 const STRUCT_KINDS = new Set(['struct']);
 const STRUCT_KINDS = new Set(['struct']);
 
 
+/** Index of the ')' that matches the '(' at openIdx, or -1 if unbalanced. */
+function findMatchingParen(s: string, openIdx: number): number {
+  let depth = 0;
+  for (let i = openIdx; i < s.length; i++) {
+    if (s[i] === '(') depth++;
+    else if (s[i] === ')') {
+      depth--;
+      if (depth === 0) return i;
+    }
+  }
+  return -1;
+}
+
 /**
 /**
  * Resolve a symbol by name using indexed queries instead of scanning all files.
  * Resolve a symbol by name using indexed queries instead of scanning all files.
  */
  */