diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ab730c2d..4b1626830 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 +- A Swift call through a nested type, like `API.PackageController.GetRoute.query(on: db)`, now links to that type's method. Only `query` used to be kept, so the call linked to whichever type's `query` came first; in a Vapor app every route type has one, so callers, impact and `codegraph_explore` answers pointed at another endpoint's code. The same applies to nested types' initializers (`API.PackageController.Model(name:)`), to enum cases (`Gitlab.Error.requestFailed(status:)`), to types declared in an `extension API.PackageController { … }`, and to The Composable Architecture's `Path.State.detail(…)`. A call on a type path that names no type in the project, such as an SDK's `Plot.Node.footer(…)`, is left unlinked. Re-index Swift projects after upgrading. - NestJS routes are now named by the path a request takes: the global prefix from `app.setGlobalPrefix('api')` is applied, along with its `exclude` list, and so is URI versioning from `app.enableVersioning(...)`, with `@Version`, `VERSION_NEUTRAL` and a controller's own `version` taken into account. A route that used to appear as `GET /user` is now `GET /api/v1/user`, so a front end's `this.http.get('/api/v1/user')` or `fetch` call connects to the controller method that serves it. Re-index NestJS projects after upgrading. - 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. diff --git a/__tests__/fixtures/kernel-parity/torture.swift b/__tests__/fixtures/kernel-parity/torture.swift index d0c4a3e06..6cb31f010 100644 --- a/__tests__/fixtures/kernel-parity/torture.swift +++ b/__tests__/fixtures/kernel-parity/torture.swift @@ -86,6 +86,13 @@ func freeFn(a: Int, cb: @escaping (Int) -> Void) -> Session? { x?.optCall() y!.forced() Foo.make().draw() + API.PackageController.GetRoute.query(on: db) + API.PackageController + .GetRoute.query(on: db) + API.Model(name: "x") + Self.Inner.make() + App.lower.call() + API.Ünicode.query() foo.bar().baz() "lit".upper() arr.map { $0.name } diff --git a/__tests__/swift-type-path-calls.test.ts b/__tests__/swift-type-path-calls.test.ts new file mode 100644 index 000000000..3e9d8c0e1 --- /dev/null +++ b/__tests__/swift-type-path-calls.test.ts @@ -0,0 +1,135 @@ +/** + * A Swift call through a type path lands on the type the path names. + * + * `API.PackageController.GetRoute.query(on:)` kept only `query`, which + * exact-matched whichever type's `query` came first — in a Vapor app every + * route type has one, so a health check "called" the dependency controller's + * query. The path is now kept and resolved on its type: declared nested, + * in `extension API.PackageController { … }`, in `extension A.B.C { … }`, + * shortened inside its namespace, or module-qualified. A path that names no + * project type is left unresolved. + */ +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 roots: string[] = []; +afterAll(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); +}); + +const FILES: Record = { + 'Sources/App/API.swift': `enum API {} +extension API { + enum PackageController { + enum GetRoute { + static func query(on db: Int) -> Int { db } + static func query(on db: Int, limit: Int) -> Int { db + limit } + } + struct Model { let name: String } + } +} +extension API.PackageController { + enum ShowRoute { + static func query(on db: Int) -> Int { db } + } +} +extension API.DependencyController.GetRoute { + static func query(on db: Int) -> Int { db } +} +extension API { + enum DependencyController { + enum GetRoute {} + static func inside() -> Int { PackageController.GetRoute.query(on: 0) } + } +} +`, + 'Sources/App/Other.swift': `enum Other { static func query(on db: Int) -> Int { db } } +enum Gitlab { enum Error: Swift.Error { case requestFailed(status: Int) } } +enum Remote { enum Error: Swift.Error { case requestFailed(status: Int) } } +struct Detail {} +enum Screens { + @Reducer + enum Path { case detail(Detail) } +} +enum Zed { enum GetRoute { static func query(on db: Int) -> Int { db } } } +enum Yon { enum GetRoute { static func query(on db: Int) -> Int { db } } } +`, + 'Sources/App/Basics.swift': `struct BasicsView {} +struct ObservableBasicsView {} +extension BasicsView { struct Feature {} } +extension ObservableBasicsView { struct Feature {} } +extension BasicsView.Feature { struct State {} } +extension ObservableBasicsView.Feature { struct State {} } +`, + 'Sources/App/Health.swift': `import Foundation +enum Health { + static func one() -> Int { API.PackageController.GetRoute.query(on: 1) } + static func two() -> Int { + API.PackageController.GetRoute + .query(on: 2) + } + static func three() -> Int { Other.query(on: 3) } + static func four() -> Int { API.PackageController.ShowRoute.query(on: 4) } + static func five() -> Int { API.DependencyController.GetRoute.query(on: 5) } + static func six() -> API.PackageController.Model { API.PackageController.Model(name: "x") } + static func seven() -> Int { App.Zed.GetRoute.query(on: 7) } + static func eight() -> Int { Missing.Thing.query(on: 8) } + static func nine() -> Int { API.PackageController.GetRoute.nothing(on: 9) } + static func ten() throws { throw Gitlab.Error.requestFailed(status: 10) } + static func eleven() { _ = Screens.Path.State.detail(Detail()) } + static func twelve() { _ = ObservableBasicsView.Feature.State() } +} +`, +}; + +describe('Swift: a call through a type path', () => { + it('lands on the type the path names, or nowhere', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-swift-type-path-')); + roots.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); + } + const cg = await CodeGraph.init(root, { index: true }); + try { + const reached = (caller: string): string[] => { + const from = cg.getNodesByName(caller).find((n) => n.kind === 'method'); + expect(from, caller).toBeDefined(); + return cg + .getOutgoingEdgesFrom([from!.id], ['calls', 'instantiates']) + .map((e) => cg.getNode(e.target)) + .filter((n) => n && n.language === 'swift') + .map((n) => `${n!.qualifiedName}:${n!.startLine}`) + .sort(); + }; + // The first declared of GetRoute's two overloads. + expect(reached('one')).toEqual(['API::PackageController::GetRoute::query:5']); + expect(reached('two')).toEqual(['API::PackageController::GetRoute::query:5']); + expect(reached('three')).toEqual(['Other::query:1']); + // Declared in `extension API.PackageController { enum ShowRoute … }`. + expect(reached('four')).toEqual(['PackageController::ShowRoute::query:13']); + // Declared in `extension API.DependencyController.GetRoute { … }`. + expect(reached('five')).toEqual(['GetRoute::query:17']); + // A nested type's initializer. + expect(reached('six')).toEqual(['API::PackageController::Model:8']); + // A module qualifier. + expect(reached('seven')).toEqual(['Zed::GetRoute::query:9']); + // No such type, and no such member: nothing, not some other type's `query`. + expect(reached('eight')).toEqual([]); + expect(reached('nine')).toEqual([]); + // A case with associated values. + expect(reached('ten')).toEqual(['Gitlab::Error::requestFailed:2']); + // The case a TCA `@Reducer enum` generates its State from. + expect(reached('eleven')).toContain('Screens::Path::detail:7'); + // Two types share the qualified name `Feature::State`; the extension each is declared in tells them apart. + expect(reached('twelve')).toEqual(['Feature::State:6']); + // Shortened inside its namespace. + expect(reached('inside')).toEqual(['API::PackageController::GetRoute::query:5']); + } finally { + cg.close(); + } + }); +}); diff --git a/codegraph-kernel/src/swift.rs b/codegraph-kernel/src/swift.rs index 1613888c3..f1d3ae4ac 100644 --- a/codegraph-kernel/src/swift.rs +++ b/codegraph-kernel/src/swift.rs @@ -110,6 +110,24 @@ fn strip_js_ws(s: &str) -> String { s.chars().filter(|c| !is_js_space(*c)).collect() } +/// SWIFT_TYPE_PATH_RECEIVER (tree-sitter.ts): `API.PackageController.GetRoute` — +/// two segments or more, each an ASCII capital then `[A-Za-z0-9_]*` (JS `\w`), +/// not led by `Self.`. +fn is_type_path(s: &str) -> bool { + if s.starts_with("Self.") { + return false; + } + let mut segments = 0; + for seg in s.split('.') { + let b = seg.as_bytes(); + if b.is_empty() || !b[0].is_ascii_uppercase() || !b[1..].iter().all(|c| c.is_ascii_alphanumeric() || *c == b'_') { + return false; + } + segments += 1; + } + segments >= 2 +} + struct Scope { row: u32, kind: &'static str, @@ -1061,8 +1079,16 @@ impl<'t> Walker<'t> { } else { method_name.to_string() }; + } else if let Some(path) = receiver + .filter(|r| r.kind() == "navigation_expression") + .map(|r| strip_js_ws(self.text(r))) + .filter(|p| is_type_path(p)) + { + // A type path, `API.PackageController.GetRoute.query(on:)`: + // keep it for the resolver (tree-sitter.ts SWIFT_TYPE_PATH_RECEIVER). + callee_name = format!("{path}.{method_name}"); } else { - // self_expression / super_expression / inner nav / + // self_expression / super_expression / instance nav / // postfix / multi_line_string_literal → bare method name. callee_name = method_name.to_string(); } diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 19c16471a..6522bcfbd 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -411,6 +411,8 @@ const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx'] const TS_JS_CHAIN_RECEIVER_TYPES = new Set(['member_expression', 'subscript_expression']); /** The field of a `this..()` receiver: public or ES private (#1496, #1987). */ const THIS_FIELD_PROPERTY_TYPES = new Set(['property_identifier', 'private_property_identifier']); +/** A Swift receiver that is a path of types, `API.PackageController.GetRoute` — two segments or more, each capitalized. */ +const SWIFT_TYPE_PATH_RECEIVER = /^(?!Self\.)[A-Z]\w*(?:\.[A-Z]\w*)+$/; /** * Identifier-rooted member chains have no inferred property type (#1566), @@ -4902,6 +4904,21 @@ export class TreeSitterExtractor { else reencode = !!innerCallee; } calleeName = reencode ? `${innerCallee}().${methodName}` : methodName; + } else if ( + this.language === 'swift' && + receiver && + receiver.type === 'navigation_expression' && + SWIFT_TYPE_PATH_RECEIVER.test(getNodeText(receiver, this.source).replace(/\s+/g, '')) + ) { + // Swift call through a type path — `API.PackageController.GetRoute.query(on:)`, + // on one line or split before the `.query`. Keep the path: the + // bare method name this used to emit exact-matched whichever + // type's `query` came first (every route in a Vapor app has one). + // The resolver finds the member on the type the path names, or + // leaves the call unresolved. An instance chain (`self.store.load()`, + // `viewModel.state.reset()`) is not a type path and stays bare. + // Mirrored in the kernel's extract_call (swift.rs). + calleeName = `${getNodeText(receiver, this.source).replace(/\s+/g, '')}.${methodName}`; } else if ( this.language === 'cfscript' && receiver && diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index d2675bddd..878b820a7 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9,6 +9,7 @@ import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, isSupertypeTarget, CPP_DEFINE_SIGNATURE, isInheritanceRef, isImportableKind } from './types'; import { blankStringContents, stripCommentsForRegex } from './strip-comments'; import { JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins'; +import { SWIFT_TYPE_PATH_CALL, resolveSwiftTypePathCall } from './swift-type-visibility'; /** * Ceiling on how many same-named definitions a FUZZY name-match strategy will * score. A name defined more times than this is "ubiquitous" — a method/symbol @@ -4383,6 +4384,13 @@ function matchReferenceInner( if (isUnresolvedJsMemberCall(ref)) return null; + // A Swift call through a type path (`API.PackageController.GetRoute.query`) + // resolves on the type the path names, or not at all: the strategies below + // would bind it by the member's name alone. + if (ref.language === 'swift' && ref.referenceKind === 'calls' && SWIFT_TYPE_PATH_CALL.test(ref.referenceName)) { + return nmTimed('swiftTypePath', ref, () => resolveSwiftTypePathCall(ref, context)); + } + // Try strategies in order of confidence let result: ResolvedRef | null; diff --git a/src/resolution/swift-type-visibility.ts b/src/resolution/swift-type-visibility.ts index 9c6442ce6..0e1446c18 100644 --- a/src/resolution/swift-type-visibility.ts +++ b/src/resolution/swift-type-visibility.ts @@ -39,13 +39,14 @@ interface Memo { conformances: Map; clauses: Map; inherited: Map>; + owners: Map; } const memos = new WeakMap(); function memoFor(context: ResolutionContext): Memo { let memo = memos.get(context); if (!memo) { - memo = { extension: new Map(), declaration: new Map(), conformances: new Map(), clauses: new Map(), inherited: new Map() }; + memo = { extension: new Map(), declaration: new Map(), conformances: new Map(), clauses: new Map(), inherited: new Map(), owners: new Map() }; memos.set(context, memo); } return memo; @@ -340,3 +341,120 @@ export function gateSwiftTypeTarget(result: ResolvedRef | null, ref: UnresolvedR if (!declared) return null; return declared.id === result.targetNodeId ? result : { ...result, targetNodeId: declared.id }; } + +/** + * A call through a type path, `API.PackageController.GetRoute.query` — at + * least two capitalized segments before the member (the extractor keeps the + * path only for those; see SWIFT_TYPE_PATH_RECEIVER in tree-sitter.ts). + */ +export const SWIFT_TYPE_PATH_CALL = /^(?!Self\.)[A-Z]\w*(?:\.[A-Z]\w*)+\.[A-Za-z_]\w*$/; + +/** What a type-path call can name: a method, or a case with associated values (`Gitlab.Error.requestFailed(status:)`). */ +const PATH_MEMBER_KINDS: ReadonlySet = new Set(['method', 'function', 'enum_member']); + +/** + * `API.PackageController.GetRoute.query(on:)` lands on the `query` of the + * type the path names, and `API.PackageController.Model(name:)` on that + * nested type — never on the member's name alone: in a Vapor app every + * route's type has a `query`. A path the call's own namespace lets it + * shorten (`PackageController.GetRoute` inside `extension API`) or a module + * qualifier (`Vapor.HTTPStatus`) still fits. No type on the path, or two, + * leaves the call unresolved. + */ +export function resolveSwiftTypePathCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const name = ref.referenceName; + const dot = name.lastIndexOf('.'); + const member = name.slice(dot + 1); + const path = name.slice(0, dot).split('.').join('::'); + const onPath = (kinds: ReadonlySet): Node[] => { + const exact: Node[] = []; + const shortened: Node[] = []; + for (const n of context.getNodesByName(member)) { + if (n.language !== 'swift' || !kinds.has(n.kind)) continue; + if (TYPE_KINDS.has(n.kind) && isSwiftExtension(n, context)) continue; + const owner = ownerPath(n, context); + if (owner === null) continue; + if (owner === path) exact.push(n); + else if (owner.endsWith(`::${path}`) || moduleQualified(owner, path, context)) shortened.push(n); + } + return exact.length > 0 ? exact : shortened; + }; + // `API.PackageController.Model(name:)` constructs a nested type. + let fit = /^[A-Z]/.test(member) ? onPath(TYPE_KINDS) : []; + if (fit.length === 0) fit = onPath(PATH_MEMBER_KINDS); + // The Composable Architecture's `@Reducer enum Path { case detail(Detail) }` + // generates `Path.State` and `Path.Action` with the same cases, so + // `Path.State.detail(…)` constructs the case written in `Path`. + if (fit.length === 0) { + const reducer = /^(.+)::(?:State|Action)$/.exec(path)?.[1]; + if (reducer) { + fit = context.getNodesByName(member).filter((n) => { + if (n.language !== 'swift' || n.kind !== 'enum_member') return false; + const owner = ownerPath(n, context); + return owner !== null && (owner === reducer || owner.endsWith(`::${reducer}`)) && isReducerEnum(n, context); + }); + } + } + if (fit.length === 0 || new Set(fit.map((n) => ownerPath(n, context))).size > 1) return null; + // Of one type's overloads, the first declared. + const target = fit.reduce((a, b) => (a.filePath < b.filePath || (a.filePath === b.filePath && a.startLine <= b.startLine) ? a : b)); + return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'qualified-name' }; +} + +/** Is this case declared in a `@Reducer enum`? */ +function isReducerEnum(member: Node, context: ResolutionContext): boolean { + const owner = context + .getNodesInFile(member.filePath) + .filter((t) => t.kind === 'enum' && t.startLine <= member.startLine && t.endLine >= member.endLine) + .reduce((inner, t) => (!inner || t.startLine >= inner.startLine ? t : inner), null); + if (!owner) return false; + const lines = linesOf(owner.filePath, context) ?? []; + // The node starts at its attributes; `@Reducer` may also sit on the line above. + return /@Reducer\b/.test(lines.slice(Math.max(0, owner.startLine - 2), owner.startLine + 1).join(' ')); +} + +/** `Vapor::HTTPStatus` for a type `HTTPStatus`: a qualifier that names no project type is a module. */ +function moduleQualified(owner: string, path: string, context: ResolutionContext): boolean { + if (!path.endsWith(`::${owner}`)) return false; + const head = path.slice(0, path.length - owner.length - 2); + return !head.includes('::') && !context.getNodesByName(head).some((n) => n.language === 'swift' && TYPE_KINDS.has(n.kind)); +} + +/** + * The full path of the type a member is declared on. Its qualified name + * starts at the outermost declaration in its file, and an extension is named + * by its last segment — the members of `extension API.PackageController { + * enum GetRoute { … } }` are `PackageController::GetRoute::…` — so the + * outermost extension's written path is read back from its line. Null for a + * top-level function. + */ +function ownerPath(member: Node, context: ResolutionContext): string | null { + const memo = memoFor(context).owners; + const hit = memo.get(member.id); + if (hit !== undefined) return hit; + const cut = member.qualifiedName.lastIndexOf('::'); + let path: string | null = cut < 0 ? null : member.qualifiedName.slice(0, cut); + if (path !== null) { + const first = path.split('::')[0]!; + const outer = context + .getNodesInFile(member.filePath) + .filter((t) => t.name === first && TYPE_KINDS.has(t.kind) && t.startLine <= member.startLine && t.endLine >= member.endLine) + .reduce((out, t) => (!out || t.startLine < out.startLine ? t : out), null); + const extended = outer ? extendedPath(outer, context) : null; + if (extended) path = extended + path.slice(first.length); + } + memo.set(member.id, path); + return path; +} + +/** `extension API.PackageController {` → `API::PackageController`; null for anything but an extension of a nested type. */ +function extendedPath(node: Node, context: ResolutionContext): string | null { + if (!isSwiftExtension(node, context)) return null; + const lines = linesOf(node.filePath, context); + if (!lines) return null; + const head = [(lines[node.startLine - 1] ?? '').slice(node.startColumn), ...lines.slice(node.startLine, node.startLine + 3)] + .join(' ') + .replace(/"(?:[^"\\\n]|\\.)*"/g, '""'); + const written = /\bextension\s+((?:[A-Za-z_]\w*\s*\.\s*)+[A-Za-z_]\w*)/.exec(head)?.[1]; + return written ? written.replace(/\s+/g, '').split('.').join('::') : null; +}