mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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/'];
|
||||
|
||||
Reference in New Issue
Block a user