diff --git a/CHANGELOG.md b/CHANGELOG.md index c4db43bbf..7b54d23d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/__tests__/vapor-closure-route-body.test.ts b/__tests__/vapor-closure-route-body.test.ts new file mode 100644 index 000000000..e0fcb080c --- /dev/null +++ b/__tests__/vapor-closure-route-body.test.ts @@ -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 = { + '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']); + }); +}); diff --git a/src/resolution/frameworks/swift.ts b/src/resolution/frameworks/swift.ts index 241953cb0..25e074a64 100644 --- a/src/resolution/frameworks/swift.ts +++ b/src/resolution/frameworks/swift.ts @@ -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(); + 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/'];