fix(vapor): a closure route's calls are the route's (#2273)

A Vapor route whose handler is a trailing closure had no handler symbol and
linked to nothing (vapor's own repo: 76 of 80 routes bound to nothing in the
viewer browser pass). The closure is the handler: its body's calls, each once
with its receiver, are referenced from the route, as an Express inline
handler's are.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-01 22:52:43 +00:00
committed by GitHub
parent f416357b93
commit 911fcf6033
3 changed files with 115 additions and 1 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- In Vapor apps, a route whose handler is a trailing closure, like `app.get("hello") { req in … }` or `app.webSocket("chat") { req, ws in … }`, now links to what the closure calls, the way an Express inline handler does. Before, the route linked to nothing, so callers, impact and flows stopped at it.
- A COBOL file whose last line ends inside the sequence area (columns 1-6), such as a closing period indented only four spaces, no longer stalls indexing for over a minute and then ends up in the index with no symbols.
- In Laravel apps, a route whose controller is named by string now links to its handler, without needing a `Controller` suffix and keeping the namespace it's written with: `'Common\Uploads@inline'`, and `Route::resource('companies', 'Common\Companies', [...])` with an options array. Two same-named controllers in different namespaces are told apart by that namespace. Before, akaunting linked 304 of its 313 routes to nothing.
- In Nuxt and Astro projects, a file-routed page is now linked to the component its file defines. An Astro endpoint is linked to the HTTP-verb handlers it exports, like `export const GET`. Before, a page route like Nuxt's `pages/admin.vue` or Astro's `src/pages/about.astro` linked to nothing, so callers, impact and flows stopped at the route.
@@ -0,0 +1,63 @@
/**
* A Vapor route whose handler is a trailing closure — `app.get("hello") { req
* in … }` — has no handler symbol, so it linked to nothing and the viewer's
* Steps tab drew it alone (vapor's own repo: 76 of 80 routes). The closure is
* the handler: its calls are the route's, as an Express inline handler's are.
* A closure after a non-route call (`if let x = req.parameters.get("x") {`)
* is not a route body.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vapor-closure-'));
const files: Record<string, string> = {
'Package.swift': `// swift-tools-version:5.9
import PackageDescription
let package = Package(name: "App", dependencies: [.package(url: "https://github.com/vapor/vapor.git", from: "4.0.0")])
`,
'Sources/App/Greeter.swift': `import Vapor
enum Greeter {
static func greeting(for name: String) -> String { "hi \\(name)" }
static func audit(_ name: String) {}
}
`,
'Sources/App/routes.swift': `import Vapor
func routes(_ app: Application) throws {
app.get("hello", ":name") { req async throws -> String in
let name = req.parameters.get("name") ?? "world"
Greeter.audit(name)
return Greeter.greeting(for: name)
}
app.webSocket("echo") { req, ws in
ws.onText { ws, text in ws.send(text) }
}
}
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
describe('a Vapor closure route', () => {
it('lends the route its closure’s calls', () => {
const route = cg.getNodesByKind('route').find((r) => r.name === 'GET /hello/:name')!;
expect(route).toBeDefined();
const callees = cg.getOutgoingEdgesFrom([route.id], ['calls']).map((e) => cg.getNode(e.target)!.qualifiedName).sort();
expect(callees).toEqual(['Greeter::audit', 'Greeter::greeting']);
});
});
+51 -1
View File
@@ -379,17 +379,67 @@ export const vaporResolver: FrameworkResolver = {
}
const line = safe.slice(0, match.index).split('\n').length;
const routePath = (groupPrefix.get(receiver!) ?? '') + segJoin('', segs) || '/';
const id = `route:${filePath}:${line}:${method}:${routePath}`;
nodes.push({
id: `route:${filePath}:${line}:${method}:${routePath}`, kind: 'route', name: `${method} ${routePath}`,
id, kind: 'route', name: `${method} ${routePath}`,
qualifiedName: `${filePath}::route:${routePath}`, filePath, startLine: line, endLine: line,
startColumn: 0, endColumn: match[0].length, language: 'swift', updatedAt: now,
});
// The closure IS the handler: its calls are the route's, as an Express
// inline handler's are, so Steps draws what `GET hello` does.
const open = match.index + match[0].length - 1;
const close = closingBrace(safe, open);
if (close > open) {
for (const name of closureCallNames(safe.slice(open + 1, close))) {
references.push({ fromNodeId: id, referenceName: name, referenceKind: 'calls', line, column: 0, filePath, language: 'swift' });
}
}
}
return { nodes, references };
},
};
/** The `}` matching the `{` at `open`; string literals are skipped. -1 when unbalanced. */
function closingBrace(s: string, open: number): number {
let depth = 0;
for (let i = open; i < s.length; i++) {
const ch = s[i];
if (ch === '"') {
for (i++; i < s.length && s[i] !== '"'; i++) if (s[i] === '\\') i++;
continue;
}
if (ch === '{') depth++;
else if (ch === '}' && --depth === 0) return i;
}
return -1;
}
/** Words Swift writes before a `(` that are not calls. */
const SWIFT_NOT_CALLS = new Set(['if', 'guard', 'switch', 'while', 'for', 'return', 'catch', 'case', 'in', 'try', 'await', 'throw', 'some', 'any']);
/**
* The calls a route closure's body makes, each once, keeping the receiver
* (`Todo.query`, `req.auth.require`) so they resolve as calls on it. A member
* of an expression (`a.b().c(`) names nothing this can follow.
*/
function closureCallNames(body: string): string[] {
const out: string[] = [];
const seen = new Set<string>();
const callRe = /((?:[A-Za-z_]\w*\s*[?!]?\.\s*)*)([A-Za-z_]\w*)\s*\(/g;
let m: RegExpExecArray | null;
while ((m = callRe.exec(body)) !== null) {
const callee = m[2]!;
const receiver = m[1]!.replace(/[\s?!]/g, '').replace(/\.$/, '');
if (!receiver && (SWIFT_NOT_CALLS.has(callee) || /[.)\]]\s*$/.test(body.slice(0, m.index)))) continue;
const name = receiver ? `${receiver}.${callee}` : callee;
if (seen.has(name)) continue;
seen.add(name);
out.push(name);
}
return out;
}
// Directory patterns
const VIEW_DIRS = ['/Views/', '/View/', '/Screens/', '/Components/', '/UI/'];
const VIEWMODEL_DIRS = ['/ViewModels/', '/ViewModel/', '/Stores/', '/Managers/', '/Services/'];