Przeglądaj źródła

feat(resolution): Vapor grouped/RouteCollection routing (was 0 routes on real apps)

The Vapor extractor only matched (app|router|routes).METHOD("path", use:
handler), but real Vapor apps route on a grouped builder inside
RouteCollection.boot(routes:): `let todos = routes.grouped("todos");
todos.get(use: index)` — any var receiver, no path arg (the path is the
group prefix). Every real app tested extracted 0 routes (template,
SteamPress, SwiftPackageIndex-Server, penny-bot, Feather).

Rewrite the extractor:
- any receiver (\w+), not just app/router/routes;
- optional path segments that may be non-string (User.parameter, :id, a
  path constant) — the `use:` keyword discriminates a route from
  Environment.get("X") / req.parameters.get("X");
- a group-prefix map from `let X = Y.grouped("a")` and
  `Y.group("a") { X in }` so a grouped/nested route gets its full path
  (todo.delete(use: delete) -> DELETE /todos/:todoID).

Result: vapor-template 0→3 (3/3, nested path exact), SteamPress 0→27
(27/27), SwiftPackageIndex-Server 0→14 (14/14 handler resolution).
Canonical flow traverses (createPostHandler <- GET /createPost ->
createPostView). Route names now carry a leading slash (GET /users),
consistent with the other frameworks.

Frontier: typed-route enums (SPI's SiteURL.x.pathComponents — handler
resolves, path label only) and closure handlers (app.get("x"){ } —
anonymous). Tests: grouped RouteCollection, self.handler + non-string
segments, use:-discriminator. Full suite green (792 passed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 miesiąc temu
rodzic
commit
3b7554fdc4
2 zmienionych plików z 70 dodań i 8 usunięć
  1. 39 2
      __tests__/frameworks.test.ts
  2. 31 6
      src/resolution/frameworks/swift.ts

+ 39 - 2
__tests__/frameworks.test.ts

@@ -945,9 +945,46 @@ describe('vaporResolver.extract', () => {
   it('extracts route from app.get with use:', () => {
     const src = `app.get("users", use: listUsers)\n`;
     const { nodes, references } = vaporResolver.extract!('routes.swift', src);
-    expect(nodes[0].name).toBe('GET users');
+    expect(nodes[0].name).toBe('GET /users');
     expect(references[0].referenceName).toBe('listUsers');
   });
+
+  it('extracts grouped RouteCollection routes with the group prefix and no path arg', () => {
+    const src = `
+func boot(routes: RoutesBuilder) throws {
+    let todos = routes.grouped("todos")
+    todos.get(use: index)
+    todos.post(use: create)
+    todos.group(":todoID") { todo in
+        todo.delete(use: delete)
+    }
+}
+`;
+    const { nodes, references } = vaporResolver.extract!('TodoController.swift', src);
+    expect(nodes.map((n) => n.name).sort()).toEqual([
+      'DELETE /todos/:todoID',
+      'GET /todos',
+      'POST /todos',
+    ]);
+    expect(references.map((r) => r.referenceName).sort()).toEqual([
+      'create',
+      'delete',
+      'index',
+    ]);
+  });
+
+  it('handles use: self.handler and non-string path segments', () => {
+    const src = `router.get("users", User.parameter, "edit", use: self.editUserHandler)\n`;
+    const { nodes, references } = vaporResolver.extract!('UserController.swift', src);
+    expect(nodes[0].name).toBe('GET /users/edit');
+    expect(references[0].referenceName).toBe('editUserHandler');
+  });
+
+  it('ignores non-route .get calls that lack use: (e.g. Environment.get)', () => {
+    const src = `let host = Environment.get("DATABASE_HOST") ?? "localhost"\n`;
+    const { nodes } = vaporResolver.extract!('configure.swift', src);
+    expect(nodes).toHaveLength(0);
+  });
 });
 
 import { reactResolver } from '../src/resolution/frameworks/react';
@@ -1120,7 +1157,7 @@ public IActionResult ListUsers() { return Ok(); }
 app.get("real", use: listUsers)
 `;
     const { nodes, references } = vaporResolver.extract!('routes.swift', src);
-    expect(nodes.map((n) => n.name)).toEqual(['GET real']);
+    expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
     expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
   });
 

+ 31 - 6
src/resolution/frameworks/swift.ts

@@ -341,13 +341,39 @@ export const vaporResolver: FrameworkResolver = {
     const now = Date.now();
     const safe = stripCommentsForRegex(content, 'swift');
 
-    // Vapor: (app|router|routes).METHOD("path", use: handler)
-    const routeRegex = /\b(?:app|router|routes)\.(get|post|put|patch|delete)\s*\(\s*"([^"]+)"\s*,\s*use:\s*([A-Za-z_][A-Za-z0-9_.]*)/g;
+    // Build a group-var → path-prefix map first. Modern Vapor routes live on a
+    // grouped builder (`let todos = routes.grouped("todos"); todos.get(use: index)`
+    // or `routes.group("todos") { todos in todos.get(use: index) }`), so the path
+    // comes from the group, not the call. Roots (app/routes/router) have no prefix.
+    const groupPrefix = new Map<string, string>();
+    const segJoin = (existing: string, segsStr: string): string => {
+      const segs = (segsStr.match(/"([^"]*)"/g) || []).map((s) => s.slice(1, -1));
+      return existing + segs.map((s) => '/' + s).join('');
+    };
+    let gm: RegExpExecArray | null;
+    // let X = Y.grouped("a", "b")
+    const groupedRegex = /\blet\s+(\w+)\s*=\s*(\w+)\.grouped\s*\(([^)]*)\)/g;
+    while ((gm = groupedRegex.exec(safe)) !== null) {
+      groupPrefix.set(gm[1]!, segJoin(groupPrefix.get(gm[2]!) ?? '', gm[3]!));
+    }
+    // Y.group("a") { X in ... }
+    const groupClosureRegex = /\b(\w+)\.group\s*\(([^)]*)\)\s*\{\s*(\w+)\s+in/g;
+    while ((gm = groupClosureRegex.exec(safe)) !== null) {
+      groupPrefix.set(gm[3]!, segJoin(groupPrefix.get(gm[1]!) ?? '', gm[2]!));
+    }
+
+    // Vapor: <builder>.METHOD([path segs,] use: handler). Any receiver (app,
+    // routes, or a grouped var); path segments optional and may be non-string
+    // (`BlogUser.parameter`, `:id`, a path constant) so accept any comma-separated
+    // args before `use:` — the label keeps only the string parts. `use:`
+    // discriminates a real route from Environment.get("X")/req.parameters.get("X").
+    const routeRegex = /\b(\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*((?:[^,()]+,\s*)*)use:\s*([A-Za-z_][\w.]*)/g;
     let match: RegExpExecArray | null;
     while ((match = routeRegex.exec(safe)) !== null) {
-      const [, method, routePath, handlerExpr] = match;
+      const [, receiver, method, segsStr, handlerExpr] = match;
       const line = safe.slice(0, match.index).split('\n').length;
       const upper = method!.toUpperCase();
+      const routePath = (groupPrefix.get(receiver!) ?? '') + segJoin('', segsStr!) || '/';
 
       const routeNode: Node = {
         id: `route:${filePath}:${line}:${upper}:${routePath}`,
@@ -364,9 +390,8 @@ export const vaporResolver: FrameworkResolver = {
       };
       nodes.push(routeNode);
 
-      // Last segment of dotted path (e.g. UserController.list -> list)
-      const parts = handlerExpr!.split('.');
-      const handlerName = parts[parts.length - 1];
+      // Last segment of a dotted handler (self.list / UserController.list -> list)
+      const handlerName = handlerExpr!.split('.').pop();
       if (handlerName) {
         references.push({
           fromNodeId: routeNode.id,