diff --git a/CHANGELOG.md b/CHANGELOG.md index 21616899f..4213efb67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Laravel routes are now named by the path a request takes: a leading `/` is added where the routes file leaves it out, `Route::prefix()` and `Route::group(['prefix' => …])` groups are applied, and routes in `routes/api.php` carry the `/api` prefix Laravel serves them under, read from your `RouteServiceProvider`, `bootstrap/app.php` or any file that mounts a routes file. A front-end `fetch('/api/…')` now connects to the Laravel route that serves it. Re-index Laravel projects after upgrading. - Controllers that Spring or Laravel tests exercise by URL now count as tested. That covers MockMvc's `perform(post("/owners/new"))`, WebTestClient, TestRestTemplate, RestAssured, Laravel's `$this->postJson('api/me')`, Pest's `get('/about')`, and a project's own request helpers built on them. `codegraph_explore` now names the test suite that reaches such an endpoint, where before every one of them looked untested. Re-index Spring and Laravel projects after upgrading. +- A Vapor route now links to the handler it names. `use: SearchController.show` used to link to whichever controller's `show` came first, so routes with a common handler name, like `show`, `index` or `get`, pointed at another endpoint's code in callers, impact and `codegraph_explore` answers. Nested types like `API.PackageController.get` and handlers declared in an extension of the controller now resolve too, and `use: self.index` resolves to the collection's own `index`. Re-index Vapor projects after upgrading. - A PHP call written without a receiver, such as `redirect($url)`, `view('books.show')`, `auth()` or `basename($path)`, is a function call, and no longer links to a same-named method, field or class elsewhere in the project. These wrong links showed up in callers, impact and `codegraph_explore` answers wherever a Laravel helper or PHP built-in shared its name with a project member. Re-index PHP projects after upgrading. ## [1.6.1] - 2026-09-29 diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index 27919c3d1..cc03c7702 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -1652,7 +1652,7 @@ describe('vaporResolver.extract', () => { const src = `app.get("users", use: listUsers)\n`; const { nodes, references } = vaporResolver.extract!('routes.swift', src); expect(nodes[0].name).toBe('GET /users'); - expect(references[0].referenceName).toBe('listUsers'); + expect(references[0].referenceName).toBe('@listUsers'); }); it('extracts grouped RouteCollection routes with the group prefix and no path arg', () => { @@ -1673,9 +1673,9 @@ func boot(routes: RoutesBuilder) throws { 'POST /todos', ]); expect(references.map((r) => r.referenceName).sort()).toEqual([ - 'create', - 'delete', - 'index', + '@create', + '@delete', + '@index', ]); }); @@ -1683,7 +1683,7 @@ func boot(routes: RoutesBuilder) throws { 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'); + expect(references[0].referenceName).toBe('@editUserHandler'); }); it('ignores non-route .get calls that lack use: (e.g. Environment.get)', () => { @@ -1725,11 +1725,11 @@ app.get( 'GET /multi/line', ]); expect(references.map((r) => r.referenceName)).toEqual([ - 'list', - 'listUsers', - 'edit', - 'update', - 'multiLine', + '@list', + '@listUsers', + 'UserController@edit', + '@update', + '@multiLine', ]); }); }); @@ -2014,7 +2014,7 @@ app.get("real", use: listUsers) `; const { nodes, references } = vaporResolver.extract!('routes.swift', src); expect(nodes.map((n) => n.name)).toEqual(['GET /real']); - expect(references.map((r) => r.referenceName)).toEqual(['listUsers']); + expect(references.map((r) => r.referenceName)).toEqual(['@listUsers']); }); it('nestjs: skips // and /* */ commented decorators', () => { diff --git a/__tests__/vapor-route-handlers.test.ts b/__tests__/vapor-route-handlers.test.ts new file mode 100644 index 000000000..4884a1609 --- /dev/null +++ b/__tests__/vapor-route-handlers.test.ts @@ -0,0 +1,194 @@ +/** + * A Vapor route binds to the handler it names — on the type it names. + * + * `app.get("search", use: SearchController.show)` used to keep only `show`, + * and name matching bound every controller's `show` route to whichever `show` + * came first (SwiftPackageIndex: 7 of 13 routes drew another endpoint's code). + */ +import { describe, it, expect, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const projects: string[] = []; +afterAll(() => { + for (const p of projects.splice(0)) fs.rmSync(p, { recursive: true, force: true }); +}); + +async function project(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vapor-handlers-')); + projects.push(root); + 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); + } + return CodeGraph.init(root, { index: true }); +} + +/** Each route's handler, as `METHOD path -> Owner::method (file)`. */ +function handlers(cg: CodeGraph): string[] { + return cg + .getNodesByKind('route') + .map((route) => { + const bound = cg.getCallees(route.id).map((c) => `${c.node.qualifiedName} (${path.basename(c.node.filePath)})`); + return `${route.name} :${route.startLine} -> ${bound.join(', ') || 'nothing'}`; + }) + .sort(); +} + +const PACKAGE = `// 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")]) +`; + +const controller = (name: string, method = 'show') => `import Vapor +enum ${name} { + static func ${method}(req: Request) async throws -> String { "${name}" } +} +`; + +describe('Vapor: use: Type.method binds to that type', () => { + it('binds same-named handlers on different controllers, nested API types and extensions to their own', async () => { + const cg = await project({ + 'Package.swift': PACKAGE, + 'Sources/App/routes.swift': `import Vapor +func routes(_ app: Application) throws { + app.get("authors", use: AuthorController.show) + app.get("search", use: SearchController.show) + app.get("health", use: HealthCheckController.show) + app.get("packages", use: PackageController.show) + app.get("api", "packages", use: API.PackageController.get) + app.get("api", "search", use: API.SearchController.get) + app.get("missing", use: MissingController.show) +} +`, + 'Sources/App/Controllers/AuthorController.swift': controller('AuthorController'), + 'Sources/App/Controllers/SearchController.swift': controller('SearchController'), + 'Sources/App/Controllers/HealthCheckController.swift': controller('HealthCheckController'), + 'Sources/App/Controllers/PackageController.swift': `import Vapor +enum PackageController {} +`, + // A handler declared in an extension of the type, in another file. + 'Sources/App/Controllers/PackageController+routes.swift': `import Vapor +extension PackageController { + static func show(req: Request) async throws -> String { "package" } + static func get(req: Request) async throws -> String { "html get" } +} +`, + 'Sources/App/Controllers/API/API.swift': 'enum API {}\n', + 'Sources/App/Controllers/API/API+PackageController.swift': `import Vapor +extension API { + enum PackageController { + static func get(req: Request) async throws -> String { "api package" } + } +} +`, + // Declared in \`extension API.SearchController\` — its node is named + // \`SearchController\`, the same qualified name the HTML controller's has. + 'Sources/App/Controllers/API/API+SearchController.swift': `import Vapor +extension API { + enum SearchController {} +} +extension API.SearchController { + static func get(req: Request) async throws -> String { "api search" } +} +`, + }); + try { + expect(handlers(cg)).toEqual([ + 'GET /api/packages :7 -> API::PackageController::get (API+PackageController.swift)', + 'GET /api/search :8 -> SearchController::get (API+SearchController.swift)', + 'GET /authors :3 -> AuthorController::show (AuthorController.swift)', + 'GET /health :5 -> HealthCheckController::show (HealthCheckController.swift)', + // Never another controller's `show`. + 'GET /missing :9 -> nothing', + 'GET /packages :6 -> PackageController::show (PackageController+routes.swift)', + 'GET /search :4 -> SearchController::show (SearchController.swift)', + ]); + } finally { + cg.close(); + } + }); + + it("picks the overload that takes the request, wherever it sits in the file", async () => { + const cg = await project({ + 'Package.swift': PACKAGE, + 'Sources/App/routes.swift': `import Vapor +func routes(_ app: Application) throws { + app.get("sitemap", use: SiteMapController.index) +} +`, + 'Sources/App/Controllers/SiteMapController.swift': `import Vapor +enum SiteMapController { + static func index(packages: [String]) -> String { packages.joined() } + @Sendable + static func index(req: Request) async throws -> String { index(packages: []) } +} +`, + }); + try { + const route = cg.getNodesByKind('route')[0]!; + expect(cg.getCallees(route.id).map((c) => `${c.node.qualifiedName}:${c.node.startLine}`)).toEqual(['SiteMapController::index:4']); + } finally { + cg.close(); + } + }); + + it('binds use: self.x and a bare x to the method of the type the route is written in', async () => { + const cg = await project({ + 'Package.swift': PACKAGE, + 'Sources/App/Controllers/Collections.swift': `import Vapor +struct TodoController: RouteCollection { + func boot(routes: RoutesBuilder) throws { + let todos = routes.grouped("todos") + todos.get(use: index) + todos.post(use: self.create) + } + func index(req: Request) async throws -> String { "todos" } + func create(req: Request) async throws -> String { "new todo" } +} +struct UserController: RouteCollection { + func boot(routes: RoutesBuilder) throws { + let users = routes.grouped("users") + users.get(use: index) + } + func index(req: Request) async throws -> String { "users" } +} +`, + // A handler declared in another extension of the collection's type. + 'Sources/App/Controllers/Tags.swift': `import Vapor +struct TagController: RouteCollection { + func boot(routes: RoutesBuilder) throws { + routes.get("tags", use: self.index) + } +} +`, + 'Sources/App/Controllers/Tags+handlers.swift': `import Vapor +extension TagController { + func index(req: Request) async throws -> String { "tags" } +} +`, + 'Sources/App/routes.swift': `import Vapor +func routes(_ app: Application) throws { + let todos = TodoController() + app.get("all-todos", use: todos.index) + app.get("hello", use: hello) +} +func hello(req: Request) -> String { "hi" } +`, + }); + try { + expect(handlers(cg)).toEqual([ + 'GET /all-todos :4 -> TodoController::index (Collections.swift)', + 'GET /hello :5 -> hello (routes.swift)', + 'GET /tags :4 -> TagController::index (Tags+handlers.swift)', + 'GET /todos :5 -> TodoController::index (Collections.swift)', + 'GET /users :14 -> UserController::index (Collections.swift)', + 'POST /todos :6 -> TodoController::create (Collections.swift)', + ]); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/frameworks/swift.ts b/src/resolution/frameworks/swift.ts index 5e1bf42cd..8c9639a72 100644 --- a/src/resolution/frameworks/swift.ts +++ b/src/resolution/frameworks/swift.ts @@ -291,7 +291,24 @@ export const vaporResolver: FrameworkResolver = { return false; }, + // A route's handler ref (`SearchController@show`, `@index`) names no declared + // symbol, so resolveOne's pre-filter would drop it before resolve() runs. + claimsReference(name: string): boolean { + return VAPOR_HANDLER.test(name); + }, + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + // Pattern 0: a route's handler — `use: SearchController.show` arrives as + // `SearchController@show`, `use: self.index` / `use: index` as `@index`. + // Resolved on the type the route names, never by the method's name alone: + // every controller has a `show`. No match, or two, is left unresolved. + const handler = VAPOR_HANDLER.exec(ref.referenceName); + if (handler) { + if (ref.language !== 'swift') return null; + const target = resolveVaporHandler(handler[1] ?? null, handler[2]!, ref, context); + return target ? { original: ref, targetNodeId: target, confidence: 0.9, resolvedBy: 'framework' } : null; + } + // Pattern 1: Controller references if (ref.referenceName.endsWith('Controller')) { const result = resolveByNameAndKind(ref.referenceName, VAPOR_CONTROLLER_KINDS, VAPOR_CONTROLLER_DIRS, context); @@ -378,6 +395,10 @@ export const vaporResolver: FrameworkResolver = { // be indexed as a route (groups `["req","get","foo.","bar"]`) where both // this pattern and the original match nothing. const routeRegex = /\b(\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*((?:[^,()]+,)*\s*)use:\s*([A-Za-z_][\w.]*)/g; + // `let todos = TodoController()` — what a `use: todos.index` receiver is. + const receiverTypes = new Map(); + const receiverRegex = /\b(?:let|var)\s+([a-z_]\w*)\s*(?::\s*([A-Z]\w*)\s*)?=\s*([A-Z][\w.]*)\s*\(/g; + while ((gm = receiverRegex.exec(safe)) !== null) receiverTypes.set(gm[1]!, gm[2] ?? gm[3]!); let match: RegExpExecArray | null; while ((match = routeRegex.exec(safe)) !== null) { const [, receiver, method, segsStr, handlerExpr] = match; @@ -400,8 +421,7 @@ export const vaporResolver: FrameworkResolver = { }; nodes.push(routeNode); - // Last segment of a dotted handler (self.list / UserController.list -> list) - const handlerName = handlerExpr!.split('.').pop(); + const handlerName = vaporHandlerRef(handlerExpr!, receiverTypes); if (handlerName) { references.push({ fromNodeId: routeNode.id, @@ -430,6 +450,103 @@ const VAPOR_CONTROLLER_DIRS = ['/Controllers/', '/Controller/', '/Routes/']; const FLUENT_MODEL_DIRS = ['/Models/', '/Model/', '/Entities/', '/Database/']; const VAPOR_MIDDLEWARE_DIRS = ['/Middleware/', '/Middlewares/']; +/** A Vapor route's handler ref: `Type@method` (the type may be dotted), or `@method`. */ +const VAPOR_HANDLER = /^((?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*)?@([A-Za-z_]\w*)$/; + +/** + * The handler ref for `use: `, keeping the type the method is on: + * `API.PackageController.get` → `API.PackageController@get`; `self.index` and + * a bare `index` → `@index` (the type the route is written in); `todos.index` + * → the type `todos` was made from, when the file says. + */ +function vaporHandlerRef(expr: string, receiverTypes: ReadonlyMap): string | null { + const segs = expr.split('.').filter((s) => s.length > 0); + const method = segs.pop(); + if (!method) return null; + if (segs[0] === 'self' || segs[0] === 'Self') segs.shift(); + if (segs.length === 0) return `@${method}`; + if (/^[A-Z]/.test(segs[0]!)) return `${segs.join('.')}@${method}`; + const type = segs.length === 1 ? receiverTypes.get(segs[0]!) : undefined; + return type ? `${type}@${method}` : `@${method}`; +} + +/** + * One type's method among `candidates`, or null when they belong to different + * types. Of a type's overloads, the one declared to take a `Request` — what + * Vapor calls a handler with — else the earliest. + */ +function oneOwnersMethod(candidates: Node[], ownerOf: (n: Node) => string, context: ResolutionContext): string | null { + if (candidates.length === 0) return null; + if (new Set(candidates.map(ownerOf)).size > 1) return null; + const earliest = (nodes: Node[]) => nodes.reduce((a, b) => (a.startLine <= b.startLine ? a : b)).id; + if (candidates.length === 1) return candidates[0]!.id; + const takesRequest = candidates.filter((n) => { + const lines = context.readFile(n.filePath)?.split(/\r?\n/) ?? []; + const head = lines.slice(n.startLine - 1, Math.min(n.endLine, n.startLine + 2)).join(' '); + return /\(\s*(?:\w+\s+)?\w+\s*:\s*Request\b/.test(head); + }); + return earliest(takesRequest.length > 0 ? takesRequest : candidates); +} + +function resolveVaporHandler(typePath: string | null, method: string, ref: UnresolvedRef, context: ResolutionContext): string | null { + const callables = context + .getNodesByName(method) + .filter((n) => (n.kind === 'method' || n.kind === 'function') && n.language === 'swift'); + if (callables.length === 0) return null; + const ownerOf = (n: Node): string => n.qualifiedName.slice(0, Math.max(0, n.qualifiedName.length - method.length - 2)); + + if (typePath === null) { + // `use: self.index` / `use: index`: the type whose body holds the route — + // its own method in this file, else one declared in another extension of it. + const owner = context + .getNodesInFile(ref.filePath) + .filter((n) => SWIFT_TYPE_KINDS.has(n.kind) && n.startLine <= ref.line && n.endLine >= ref.line) + .reduce((inner, n) => (!inner || n.startLine >= inner.startLine ? n : inner), null); + if (!owner) { + // A route in a top-level `func routes(_ app:)` names a function in scope. + const functions = callables.filter((n) => n.kind === 'function'); + const sameFile = functions.filter((n) => n.filePath === ref.filePath); + if (sameFile.length > 0) return oneOwnersMethod(sameFile, ownerOf, context); + return functions.length === 1 ? functions[0]!.id : null; + } + const own = callables.filter( + (n) => n.filePath === ref.filePath && n.startLine >= owner.startLine && n.endLine <= owner.endLine + ); + if (own.length > 0) return oneOwnersMethod(own, ownerOf, context); + typePath = owner.qualifiedName.replace(/::/g, '.'); + } + + const segs = typePath.split('.'); + const type = segs[segs.length - 1]!; + const full = segs.join('::'); + const exact = callables.filter((n) => ownerOf(n) === full); + if (exact.length > 0) return oneOwnersMethod(exact, ownerOf, context); + const nested = callables.filter((n) => ownerOf(n).endsWith(`::${full}`)); + if (nested.length > 0) return oneOwnersMethod(nested, ownerOf, context); + if (segs.length > 1) { + // `extension API.PackageController { static func get }` names its node by + // the last segment (`PackageController::get`) — the same QN a top-level + // `PackageController`'s method has, so the extension's own line decides. + const declared = new RegExp(String.raw`\bextension\s+${segs.join(String.raw`\s*\.\s*`)}\b`); + const inExtension = callables.filter((n) => { + if (ownerOf(n) !== type) return false; + const owner = context + .getNodesInFile(n.filePath) + .filter((o) => SWIFT_TYPE_KINDS.has(o.kind) && o.name === type && o.startLine <= n.startLine && o.endLine >= n.endLine) + .reduce((inner, o) => (!inner || o.startLine >= inner.startLine ? o : inner), null); + if (!owner) return false; + // The node starts at its attributes (`@available(…)`), the keyword a line or two on. + const head = (context.readFile(n.filePath)?.split(/\r?\n/) ?? []).slice(owner.startLine - 1, owner.startLine + 2).join(' '); + return declared.test(head); + }); + return oneOwnersMethod(inExtension, ownerOf, context); + } + // A type named without the namespace it is nested in — only when one type fits. + return oneOwnersMethod(callables.filter((n) => ownerOf(n).endsWith(`::${type}`)), ownerOf, context); +} + +const SWIFT_TYPE_KINDS = new Set(['class', 'struct', 'enum', 'protocol']); + const VIEW_KINDS = new Set(['struct', 'component']); const CLASS_KINDS = new Set(['class']); const MODEL_KINDS = new Set(['struct', 'class']);