Просмотр исходного кода

feat(resolution): actix-web builder-API routing (web::resource / .to(handler))

Actix's attribute macros were covered, but the dominant actix style is the
builder API — web::resource("/path").route(web::get().to(handler)),
web::resource("/").to(handler) (all methods), and App .route("/path",
web::get().to(handler)). The handler is in .to(handler), not get(handler),
so the Axum .route scan extracted nothing — actix-examples had 80
web::resource calls all unlinked.

Add an actix block: scan each web::resource("/path") (bounding its method
chain at the next resource) for web::METHOD().to(h) pairs, fall back to a
direct .to(h) (method ANY), plus the App-level .route("/x",
web::METHOD().to(h)) form. actix-examples 51→128 routes, 35→112 resolved
(GET /user/{name}→with_param, POST /user→add_user). No regression on Axum
(realworld-axum still 19/19). Tests: resource+route, resource direct .to,
App-level route. Suite green (797).

Frontier: web::scope("/api") prefixes not prepended; anonymous .to(|req|…)
closures have no named target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 месяц назад
Родитель
Сommit
b9c2cbf2f3
2 измененных файлов с 85 добавлено и 0 удалено
  1. 21 0
      __tests__/frameworks.test.ts
  2. 64 0
      src/resolution/frameworks/rust.ts

+ 21 - 0
__tests__/frameworks.test.ts

@@ -613,6 +613,27 @@ let app = Router::new()
     expect(nodes[0].name).toBe('GET /articles/feed');
     expect(references[0].referenceName).toBe('feed_articles');
   });
+
+  it('extracts actix web::resource().route(web::METHOD().to(handler))', () => {
+    const src = `App::new().service(web::resource("/user/{id}").route(web::get().to(get_user)))\n`;
+    const { nodes, references } = rustResolver.extract!('main.rs', src);
+    expect(nodes[0].name).toBe('GET /user/{id}');
+    expect(references[0].referenceName).toBe('get_user');
+  });
+
+  it('extracts actix web::resource("/").to(handler) (all methods)', () => {
+    const src = `App::new().service(web::resource("/").to(index))\n`;
+    const { nodes, references } = rustResolver.extract!('main.rs', src);
+    expect(nodes[0].name).toBe('ANY /');
+    expect(references[0].referenceName).toBe('index');
+  });
+
+  it('extracts actix App-level .route("/path", web::METHOD().to(handler))', () => {
+    const src = `App::new().route("/health", web::get().to(health_check))\n`;
+    const { nodes, references } = rustResolver.extract!('main.rs', src);
+    expect(nodes[0].name).toBe('GET /health');
+    expect(references[0].referenceName).toBe('health_check');
+  });
 });
 
 describe('rustResolver.resolve cargo workspace crates', () => {

+ 64 - 0
src/resolution/frameworks/rust.ts

@@ -187,6 +187,70 @@ 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 handler = handlerExpr.split('::').filter(Boolean).pop();
+      if (!handler) return;
+      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: 0,
+        language: 'rust',
+        updatedAt: now,
+      };
+      nodes.push(routeNode);
+      references.push({
+        fromNodeId: routeNode.id,
+        referenceName: handler,
+        referenceKind: 'references',
+        line,
+        column: 0,
+        filePath,
+        language: 'rust',
+      });
+    };
+
+    // web::resource("/path") { .route(web::METHOD().to(h)) | .to(h) } — possibly chained.
+    const resourceRegex = /web::resource\s*\(\s*"([^"]+)"\s*\)/g;
+    while ((match = resourceRegex.exec(safe)) !== null) {
+      const routePath = match[1]!;
+      const startLine = safe.slice(0, match.index).split('\n').length;
+      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);
+      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;
+      let m2: RegExpExecArray | null;
+      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);
+        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);
+      }
+    }
+
+    // App-level: .route("/path", web::METHOD().to(handler)).
+    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);
+    }
+
     return { nodes, references };
   },
 };