From b8d1312595cd2b8ba0f768a38a695e55f68b7fdc Mon Sep 17 00:00:00 2001 From: leoca Date: Fri, 2 Oct 2026 09:33:45 +0000 Subject: [PATCH 1/2] fix(code-knowledge): do not resolve a Swift bare call a type member shadows An unqualified call inside a Swift type runs that type's member when one is named after the callee, and the member can come from a superclass, an `extension` in a third file or a protocol default implementation. The module-scope fallback knows only the module's top-level declarations, so it saw a same-named top-level `func` as the single candidate and claimed it, emitting a REFERENCES edge between two files that never call each other. Answering this per type would need the module's inheritance and conformance graph, which this layer does not build. Asking it of the module instead -- does any type declare this name as a member? -- needs only names already extracted, and fails the way the rest of the layer fails: a call that really was the module-level one loses its edge when an unrelated type shares the name, and a missing edge still surfaces as a gap. The walker already had the declaration node in hand, so members are collected there and carried into the module index alongside the module-visible declarations. The receiver fallback below has the same shape and is left alone: a receiver is a type name, and a member named like a type is a different case that would need its own evidence. --- src/__tests__/ast-swift-module-scope.test.ts | 45 ++++++++++++++ .../code-knowledge/ast/call-resolver.ts | 14 ++++- src/wiki-engine/code-knowledge/ast/index.ts | 8 ++- .../code-knowledge/ast/module-scope.ts | 59 ++++++++++++++++++- src/wiki-engine/code-knowledge/ast/walk.ts | 44 +++++++++++--- 5 files changed, 157 insertions(+), 13 deletions(-) diff --git a/src/__tests__/ast-swift-module-scope.test.ts b/src/__tests__/ast-swift-module-scope.test.ts index efdc30003..196d07895 100644 --- a/src/__tests__/ast-swift-module-scope.test.ts +++ b/src/__tests__/ast-swift-module-scope.test.ts @@ -256,6 +256,51 @@ describe('Swift module-scope resolution (web-tree-sitter WASM)', () => { expect(calls.get('handled')?.resolvedTargetFile).toBe('Sources/App/Service.swift'); }); + it('does not resolve a call to a member the enclosing type inherits', async () => { + const { result } = await extractFiles([ + ['Sources/App/Base.swift', 'class Base {\n func work() -> Int { return 1 }\n}\n'], + ['Sources/App/Sub.swift', 'class Sub: Base {\n func run() -> Int { return work() }\n}\n'], + ['Sources/App/Global.swift', 'func work() -> Int { return 2 }\n'], + ]); + + // `Sub` inherits `work()` from `Base`, so that member is what the call runs. + // The top-level `func work()` in a third file is not, and an edge to it is an + // invented one. + const calls = new Map(result.callSites.map((c) => [c.calleeText, c])); + expect(calls.has('work')).toBe(true); + expect(calls.get('work')?.resolvedTargetFile).toBeUndefined(); + expect(result.edges.filter((e) => e.relation === 'REFERENCES')).toHaveLength(0); + }); + + it('does not resolve a call to a member an extension in another file adds', async () => { + const { result } = await extractFiles([ + ['Sources/App/Sub.swift', 'class Sub {\n func run() -> Int { return work() }\n}\n'], + ['Sources/App/Ext.swift', 'extension Sub {\n func work() -> Int { return 3 }\n}\n'], + ['Sources/App/Global.swift', 'func work() -> Int { return 2 }\n'], + ]); + + // The same shape with the member added by an extension rather than inherited. + const calls = new Map(result.callSites.map((c) => [c.calleeText, c])); + expect(calls.has('work')).toBe(true); + expect(calls.get('work')?.resolvedTargetFile).toBeUndefined(); + expect(result.edges.filter((e) => e.relation === 'REFERENCES')).toHaveLength(0); + }); + + it('still resolves a call no type in the module declares as a member', async () => { + const { result } = await extractFiles([ + ['Sources/App/Base.swift', 'class Base {\n func other() -> Int { return 1 }\n}\n'], + ['Sources/App/Sub.swift', 'class Sub: Base {\n func run() -> Int { return work() }\n}\n'], + ['Sources/App/Global.swift', 'func work() -> Int { return 2 }\n'], + ]); + + // The control for the two cases above: no member is named `work`, so the + // module-level function is the only candidate and the edge still stands. + const references = result.edges.filter((e) => e.relation === 'REFERENCES'); + expect(references).toHaveLength(1); + expect(references[0]?.from).toBe('Sources/App/Sub.swift'); + expect(references[0]?.to).toBe('Sources/App/Global.swift'); + }); + it('does not resolve a type nested inside another file', async () => { const { result } = await extractFiles([ [ diff --git a/src/wiki-engine/code-knowledge/ast/call-resolver.ts b/src/wiki-engine/code-knowledge/ast/call-resolver.ts index d6509d4ef..c4a3e59fa 100644 --- a/src/wiki-engine/code-knowledge/ast/call-resolver.ts +++ b/src/wiki-engine/code-knowledge/ast/call-resolver.ts @@ -1,7 +1,7 @@ import type { AstCallSite, AstImport, AstSymbol } from "./types.js"; import type { ResolvedImport } from "./import-resolver.js"; import type { SwiftModuleSymbolIndex } from "./module-scope.js"; -import { findSwiftModuleSymbol } from "./module-scope.js"; +import { findSwiftModuleSymbol, swiftModuleDeclaresMember } from "./module-scope.js"; export interface ImportBindingMap { /** Local name → exported symbol id in target file */ @@ -110,7 +110,17 @@ function resolveOneCall( // the walker reports those bindings per site: `run(work:) { work() }` calls // its parameter, so claiming a sibling file's `func work()` here would // invent an edge. Missing a resolution is the better failure. - if (swiftModules && !site.localBindings?.includes(callee)) { + // + // A member of the enclosing type wins over a module-level declaration in the + // same way, and it does not have to be declared in this file to do so: a + // superclass, an `extension` or a protocol default implementation anywhere in + // the module puts it in scope. `swiftModuleDeclaresMember` is what stands in + // for the inheritance graph this layer does not build. + if ( + swiftModules && + !site.localBindings?.includes(callee) && + !swiftModuleDeclaresMember(swiftModules, site.fromFile, callee) + ) { const moduleSymbol = findSwiftModuleSymbol(swiftModules, site.fromFile, callee, ["function", "class"]); if (moduleSymbol) { return { diff --git a/src/wiki-engine/code-knowledge/ast/index.ts b/src/wiki-engine/code-knowledge/ast/index.ts index 7f97bfe98..23233d990 100644 --- a/src/wiki-engine/code-knowledge/ast/index.ts +++ b/src/wiki-engine/code-knowledge/ast/index.ts @@ -46,6 +46,7 @@ export async function extractStructuralGraph( const { repoRoot, files } = options; const symbols: StructuralGraphResult["symbols"] = []; const swiftModuleSymbols: StructuralGraphResult["symbols"] = []; + const swiftMemberSymbols: StructuralGraphResult["symbols"] = []; const imports: StructuralGraphResult["imports"] = []; const callSites: StructuralGraphResult["callSites"] = []; const implementsSites: AstImplementsSite[] = []; @@ -76,6 +77,7 @@ export async function extractStructuralGraph( filesParsed++; symbols.push(...walked.symbols); swiftModuleSymbols.push(...walked.swiftModuleSymbols); + swiftMemberSymbols.push(...walked.swiftMemberSymbols); imports.push(...walked.imports); callSites.push(...walked.callSites); implementsSites.push(...walked.implementsSites); @@ -92,8 +94,10 @@ export async function extractStructuralGraph( // SwiftPM target see each other with no import statement. Index the module // scopes once so conformance and call resolution can fall back to them — // over the module-visible declarations only, since a method or a `private` - // declaration is not reachable by name from a sibling file. - const swiftModules = buildSwiftModuleSymbolIndex(swiftModuleSymbols); + // declaration is not reachable by name from a sibling file. The members go in + // too, not as candidates but as the names a bare call inside a type may be + // referring to instead of the module level. + const swiftModules = buildSwiftModuleSymbolIndex(swiftModuleSymbols, swiftMemberSymbols); const resolvedImports = new Map>>(); const resolvedKeys = new Set(); diff --git a/src/wiki-engine/code-knowledge/ast/module-scope.ts b/src/wiki-engine/code-knowledge/ast/module-scope.ts index d284ca87e..6bf4ee5a5 100644 --- a/src/wiki-engine/code-knowledge/ast/module-scope.ts +++ b/src/wiki-engine/code-knowledge/ast/module-scope.ts @@ -63,6 +63,8 @@ export interface SwiftModuleSymbolIndex { byModule: Map; /** File → its module scope key, for files that sit inside a known module. */ scopeOfFile: Map; + /** Module scope key → every name a type in that module declares as a member. */ + memberNames: Map>; } /** @@ -75,10 +77,33 @@ export interface SwiftModuleSymbolIndex { * resolve from another file, which is exactly the fabricated edge this layer * exists to avoid. `walk.ts` decides it, at the point where the declaration node * is still in hand. + * + * `memberSymbols` is the complement the lookup below cannot do without: the + * declarations that belong to a type's body. They are not candidates — a bare + * name never reaches a member of another file's type — but they say when a bare + * name is not a candidate for the module level either, which is what + * `swiftModuleDeclaresMember` is for. */ -export function buildSwiftModuleSymbolIndex(symbols: AstSymbol[]): SwiftModuleSymbolIndex { +export function buildSwiftModuleSymbolIndex( + symbols: AstSymbol[], + memberSymbols: AstSymbol[] +): SwiftModuleSymbolIndex { const byModule = new Map(); const scopeOfFile = new Map(); + const memberNames = new Map>(); + + for (const symbol of memberSymbols) { + const scope = swiftModuleScope(symbol.file); + if (!scope) { + continue; + } + const names = memberNames.get(scope); + if (names) { + names.add(symbol.name); + } else { + memberNames.set(scope, new Set([symbol.name])); + } + } for (const symbol of symbols) { const scope = swiftModuleScope(symbol.file); @@ -94,7 +119,37 @@ export function buildSwiftModuleSymbolIndex(symbols: AstSymbol[]): SwiftModuleSy } } - return { byModule, scopeOfFile }; + return { byModule, scopeOfFile, memberNames }; +} + +/** + * Whether a type in `fromFile`'s module declares `name` as a member. + * + * A bare call inside a type runs that type's member when one is named after the + * callee, and the member can come from anywhere: the type itself, a superclass, + * an `extension` in a third file, a protocol's default implementation. None of + * those are visible to a lookup that only knows the module's top-level + * declarations, so a module-level function of the same name looks like the only + * candidate and is claimed as the target. + * + * Answering the question per type would need the inheritance and conformance + * graph of the whole module, which this layer does not build. Asking it of the + * module — does *any* type declare this member? — needs only the names already + * extracted, and errs the way the rest of the layer errs: a call that does turn + * out to be the module-level one, made from a module where some unrelated type + * declares a member of the same name, loses its edge. A missing edge still shows + * up as a gap; an invented one is read as a fact. + */ +export function swiftModuleDeclaresMember( + index: SwiftModuleSymbolIndex, + fromFile: string, + name: string +): boolean { + const scope = index.scopeOfFile.get(fromFile) ?? swiftModuleScope(fromFile); + if (!scope) { + return false; + } + return index.memberNames.get(scope)?.has(name) === true; } /** diff --git a/src/wiki-engine/code-knowledge/ast/walk.ts b/src/wiki-engine/code-knowledge/ast/walk.ts index f73493d43..3a2ee8adb 100644 --- a/src/wiki-engine/code-knowledge/ast/walk.ts +++ b/src/wiki-engine/code-knowledge/ast/walk.ts @@ -20,6 +20,14 @@ export interface FileWalkResult { * name. Empty for every other language, and a subset of `symbols` for Swift. */ swiftModuleSymbols: AstSymbol[]; + /** + * Swift only: the declarations that sit in a type's body. Disjoint from + * `swiftModuleSymbols` — a member is not reachable by a bare name from a + * sibling file — and also a subset of `symbols`. What they are needed for is + * the opposite question: a bare name *inside* a type may be one of these, in + * which case it is not the module-level declaration of the same name. + */ + swiftMemberSymbols: AstSymbol[]; imports: AstImport[]; callSites: AstCallSite[]; implementsSites: AstImplementsSite[]; @@ -35,18 +43,19 @@ export function isAstParseableFile(relativePath: string): boolean { export function walkFile(file: CodeCollectedFile): FileWalkResult { const symbols: AstSymbol[] = []; const swiftModuleSymbols: AstSymbol[] = []; + const swiftMemberSymbols: AstSymbol[] = []; const imports: AstImport[] = []; const callSites: AstCallSite[] = []; const implementsSites: AstImplementsSite[] = []; const parseErrors: string[] = []; if (!isAstParseableFile(file.relativePath)) { - return { symbols, swiftModuleSymbols, imports, callSites, implementsSites, parseErrors }; + return { symbols, swiftModuleSymbols, swiftMemberSymbols, imports, callSites, implementsSites, parseErrors }; } if (Buffer.byteLength(file.content, "utf8") > MAX_FILE_BYTES) { parseErrors.push(`skipped large file: ${file.relativePath}`); - return { symbols, swiftModuleSymbols, imports, callSites, implementsSites, parseErrors }; + return { symbols, swiftModuleSymbols, swiftMemberSymbols, imports, callSites, implementsSites, parseErrors }; } const variant = grammarForExtension(path.extname(file.relativePath))!; @@ -59,12 +68,12 @@ export function walkFile(file: CodeCollectedFile): FileWalkResult { tree = parser.parse(file.content); } catch (error) { parseErrors.push(`parse failed: ${file.relativePath}: ${error instanceof Error ? error.message : String(error)}`); - return { symbols, swiftModuleSymbols, imports, callSites, implementsSites, parseErrors }; + return { symbols, swiftModuleSymbols, swiftMemberSymbols, imports, callSites, implementsSites, parseErrors }; } if (!tree) { parseErrors.push(`parse returned null: ${file.relativePath}`); - return { symbols, swiftModuleSymbols, imports, callSites, implementsSites, parseErrors }; + return { symbols, swiftModuleSymbols, swiftMemberSymbols, imports, callSites, implementsSites, parseErrors }; } try { @@ -120,8 +129,12 @@ export function walkFile(file: CodeCollectedFile): FileWalkResult { symbols.push(symbol); // Swift files in one module see each other without any import, so the // module index needs exactly the declarations a sibling can reach. - if (variant === "swift" && isSwiftModuleVisible(decl)) { - swiftModuleSymbols.push(symbol); + if (variant === "swift") { + if (isSwiftModuleVisible(decl)) { + swiftModuleSymbols.push(symbol); + } else if (isSwiftTypeMember(decl)) { + swiftMemberSymbols.push(symbol); + } } continue; } @@ -166,7 +179,7 @@ export function walkFile(file: CodeCollectedFile): FileWalkResult { tree.delete(); } - return { symbols, swiftModuleSymbols, imports, callSites, implementsSites, parseErrors }; + return { symbols, swiftModuleSymbols, swiftMemberSymbols, imports, callSites, implementsSites, parseErrors }; } /** @@ -210,6 +223,23 @@ function isSwiftModuleVisible(decl: Node): boolean { ); } +/** + * Whether this declaration is a member of a type. + * + * tree-sitter-swift puts the members of a class, a struct, an actor or an + * `extension` in a `class_body`, an enum's in an `enum_class_body` and a + * protocol's requirements in a `protocol_body`, so the parent node answers this + * on its own. + * + * A declaration inside a function body is deliberately not a member: it sits + * under `statements`, and nothing outside that body can be referring to it. The + * enclosing-scope bindings on the call site already cover that case. + */ +function isSwiftTypeMember(decl: Node): boolean { + const parent = decl.parent?.type; + return parent === "class_body" || parent === "enum_class_body" || parent === "protocol_body"; +} + function symbolId(file: string, kind: AstSymbolKind, name: string): string { const kindLabel = kind.charAt(0).toUpperCase() + kind.slice(1); return `${file}#${kindLabel}:${name}`; From 81bc89bac6bedfb987768ccbb5e3370d8b0f8d7d Mon Sep 17 00:00:00 2001 From: leoca Date: Sat, 3 Oct 2026 02:27:20 +0000 Subject: [PATCH 2/2] fix(code-knowledge): count a Swift callable property as a type member The member-name set was built from the symbols the Swift query captures, and that query captures functions and types only. A property is callable under a bare name just as a method is -- `let work: () -> Int` inside `Base`, `work()` inside `Sub: Base` -- so a `property_declaration` missing from the set left the module-level fallback free to claim an unrelated top-level `func work()` and emit the fabricated edge this change set out to remove. Collect the names off the tree instead, once per file, from the declarations sitting directly in a `class_body`, an `enum_class_body` or a `protocol_body`. A declaration names itself either through a `name` field holding an identifier or through a `pattern` child, which is how `property_declaration` and `protocol_property_declaration` carry theirs; descending the pattern reuses what `collectSwiftShadowedNames` already does for a local binding, so a tuple `let (a, b)` contributes both names. `subscript_declaration` has neither and is not reachable by a bare name, so it contributes nothing. Names rather than symbols for the same reason: a property is not a symbol this layer extracts, so the question cannot be answered off the symbol list. --- src/__tests__/ast-swift-module-scope.test.ts | 17 ++++ src/wiki-engine/code-knowledge/ast/index.ts | 13 +-- .../code-knowledge/ast/module-scope.ts | 28 ++++-- src/wiki-engine/code-knowledge/ast/walk.ts | 97 ++++++++++++++----- 4 files changed, 113 insertions(+), 42 deletions(-) diff --git a/src/__tests__/ast-swift-module-scope.test.ts b/src/__tests__/ast-swift-module-scope.test.ts index 196d07895..c9d79d574 100644 --- a/src/__tests__/ast-swift-module-scope.test.ts +++ b/src/__tests__/ast-swift-module-scope.test.ts @@ -286,6 +286,23 @@ describe('Swift module-scope resolution (web-tree-sitter WASM)', () => { expect(result.edges.filter((e) => e.relation === 'REFERENCES')).toHaveLength(0); }); + it('does not resolve a call to a callable property a type declares', async () => { + const { result } = await extractFiles([ + ['Sources/App/Base.swift', 'class Base {\n let work: () -> Int = { 1 }\n}\n'], + ['Sources/App/Sub.swift', 'class Sub: Base {\n func run() -> Int { return work() }\n}\n'], + ['Sources/App/Global.swift', 'func work() -> Int { return 2 }\n'], + ]); + + // A property holding a closure is called under a bare name exactly like a + // method, so it shadows the module level the same way. A `property_declaration` + // is not one of the symbols the Swift query captures, which is why the member + // names are read off the tree rather than off the symbol list. + const calls = new Map(result.callSites.map((c) => [c.calleeText, c])); + expect(calls.has('work')).toBe(true); + expect(calls.get('work')?.resolvedTargetFile).toBeUndefined(); + expect(result.edges.filter((e) => e.relation === 'REFERENCES')).toHaveLength(0); + }); + it('still resolves a call no type in the module declares as a member', async () => { const { result } = await extractFiles([ ['Sources/App/Base.swift', 'class Base {\n func other() -> Int { return 1 }\n}\n'], diff --git a/src/wiki-engine/code-knowledge/ast/index.ts b/src/wiki-engine/code-knowledge/ast/index.ts index 23233d990..2b9ea4c5f 100644 --- a/src/wiki-engine/code-knowledge/ast/index.ts +++ b/src/wiki-engine/code-knowledge/ast/index.ts @@ -5,6 +5,7 @@ import { type CodeFact } from "../code-extractors.js"; import { structuralEdgesToCodeFacts, unresolvedImportsToGaps } from "./adapt-code-facts.js"; import { buildImportBindingsForFile, callResolutionWeight, resolveCallSites } from "./call-resolver.js"; import { buildFileExistenceChecker, resolveImportSpecifier } from "./import-resolver.js"; +import type { SwiftMemberName } from "./module-scope.js"; import { buildSwiftModuleSymbolIndex, findSwiftModuleSymbol } from "./module-scope.js"; import { ensureAstReady } from "./parser-registry.js"; import type { AstExtractionGap, AstImplementsSite, StructuralEdge, StructuralGraphResult } from "./types.js"; @@ -46,7 +47,7 @@ export async function extractStructuralGraph( const { repoRoot, files } = options; const symbols: StructuralGraphResult["symbols"] = []; const swiftModuleSymbols: StructuralGraphResult["symbols"] = []; - const swiftMemberSymbols: StructuralGraphResult["symbols"] = []; + const swiftMemberNames: SwiftMemberName[] = []; const imports: StructuralGraphResult["imports"] = []; const callSites: StructuralGraphResult["callSites"] = []; const implementsSites: AstImplementsSite[] = []; @@ -77,7 +78,7 @@ export async function extractStructuralGraph( filesParsed++; symbols.push(...walked.symbols); swiftModuleSymbols.push(...walked.swiftModuleSymbols); - swiftMemberSymbols.push(...walked.swiftMemberSymbols); + swiftMemberNames.push(...walked.swiftMemberNames); imports.push(...walked.imports); callSites.push(...walked.callSites); implementsSites.push(...walked.implementsSites); @@ -94,10 +95,10 @@ export async function extractStructuralGraph( // SwiftPM target see each other with no import statement. Index the module // scopes once so conformance and call resolution can fall back to them — // over the module-visible declarations only, since a method or a `private` - // declaration is not reachable by name from a sibling file. The members go in - // too, not as candidates but as the names a bare call inside a type may be - // referring to instead of the module level. - const swiftModules = buildSwiftModuleSymbolIndex(swiftModuleSymbols, swiftMemberSymbols); + // declaration is not reachable by name from a sibling file. The member names + // go in too, not as candidates but as the names a bare call inside a type may + // be referring to instead of the module level. + const swiftModules = buildSwiftModuleSymbolIndex(swiftModuleSymbols, swiftMemberNames); const resolvedImports = new Map>>(); const resolvedKeys = new Set(); diff --git a/src/wiki-engine/code-knowledge/ast/module-scope.ts b/src/wiki-engine/code-knowledge/ast/module-scope.ts index 6bf4ee5a5..f8ab2e495 100644 --- a/src/wiki-engine/code-knowledge/ast/module-scope.ts +++ b/src/wiki-engine/code-knowledge/ast/module-scope.ts @@ -58,6 +58,12 @@ export function swiftModuleScope(relativePath: string): string | undefined { return undefined; } +/** A name a type declares as a member, and the file that declares it. */ +export interface SwiftMemberName { + file: string; + name: string; +} + export interface SwiftModuleSymbolIndex { /** Module scope key → every declaration found in that module. */ byModule: Map; @@ -78,30 +84,32 @@ export interface SwiftModuleSymbolIndex { * exists to avoid. `walk.ts` decides it, at the point where the declaration node * is still in hand. * - * `memberSymbols` is the complement the lookup below cannot do without: the - * declarations that belong to a type's body. They are not candidates — a bare - * name never reaches a member of another file's type — but they say when a bare - * name is not a candidate for the module level either, which is what - * `swiftModuleDeclaresMember` is for. + * `members` is the complement the lookup below cannot do without: the names + * that belong to a type's body. They are not candidates — a bare name never + * reaches a member of another file's type — but they say when a bare name is + * not a candidate for the module level either, which is what + * `swiftModuleDeclaresMember` is for. Names rather than symbols, because a + * property is callable under a bare name and is not a symbol this layer + * extracts. */ export function buildSwiftModuleSymbolIndex( symbols: AstSymbol[], - memberSymbols: AstSymbol[] + members: SwiftMemberName[] ): SwiftModuleSymbolIndex { const byModule = new Map(); const scopeOfFile = new Map(); const memberNames = new Map>(); - for (const symbol of memberSymbols) { - const scope = swiftModuleScope(symbol.file); + for (const member of members) { + const scope = swiftModuleScope(member.file); if (!scope) { continue; } const names = memberNames.get(scope); if (names) { - names.add(symbol.name); + names.add(member.name); } else { - memberNames.set(scope, new Set([symbol.name])); + memberNames.set(scope, new Set([member.name])); } } diff --git a/src/wiki-engine/code-knowledge/ast/walk.ts b/src/wiki-engine/code-knowledge/ast/walk.ts index 3a2ee8adb..b597b2d3b 100644 --- a/src/wiki-engine/code-knowledge/ast/walk.ts +++ b/src/wiki-engine/code-knowledge/ast/walk.ts @@ -10,6 +10,7 @@ import { normalizeImportSpecifier, parseImportBindings } from "./import-bindings.js"; +import type { SwiftMemberName } from "./module-scope.js"; import { grammarForExtension, getLanguage, getParser, getQuery } from "./parser-registry.js"; import type { AstCallSite, AstImplementsSite, AstImport, AstSymbol, AstSymbolKind } from "./types.js"; @@ -21,13 +22,18 @@ export interface FileWalkResult { */ swiftModuleSymbols: AstSymbol[]; /** - * Swift only: the declarations that sit in a type's body. Disjoint from + * Swift only: every name a type in this file declares as a member, paired + * with the file so the module index can scope it. Disjoint from * `swiftModuleSymbols` — a member is not reachable by a bare name from a - * sibling file — and also a subset of `symbols`. What they are needed for is - * the opposite question: a bare name *inside* a type may be one of these, in - * which case it is not the module-level declaration of the same name. + * sibling file. What they are needed for is the opposite question: a bare + * name *inside* a type may be one of these, in which case it is not the + * module-level declaration of the same name. + * + * Names, not symbols: a `let work: () -> Int` is callable under a bare name + * just like a `func work()`, and a property is not a symbol this walker + * extracts, so the question cannot be answered off the symbol list. */ - swiftMemberSymbols: AstSymbol[]; + swiftMemberNames: SwiftMemberName[]; imports: AstImport[]; callSites: AstCallSite[]; implementsSites: AstImplementsSite[]; @@ -43,19 +49,19 @@ export function isAstParseableFile(relativePath: string): boolean { export function walkFile(file: CodeCollectedFile): FileWalkResult { const symbols: AstSymbol[] = []; const swiftModuleSymbols: AstSymbol[] = []; - const swiftMemberSymbols: AstSymbol[] = []; + const swiftMemberNames: SwiftMemberName[] = []; const imports: AstImport[] = []; const callSites: AstCallSite[] = []; const implementsSites: AstImplementsSite[] = []; const parseErrors: string[] = []; if (!isAstParseableFile(file.relativePath)) { - return { symbols, swiftModuleSymbols, swiftMemberSymbols, imports, callSites, implementsSites, parseErrors }; + return { symbols, swiftModuleSymbols, swiftMemberNames, imports, callSites, implementsSites, parseErrors }; } if (Buffer.byteLength(file.content, "utf8") > MAX_FILE_BYTES) { parseErrors.push(`skipped large file: ${file.relativePath}`); - return { symbols, swiftModuleSymbols, swiftMemberSymbols, imports, callSites, implementsSites, parseErrors }; + return { symbols, swiftModuleSymbols, swiftMemberNames, imports, callSites, implementsSites, parseErrors }; } const variant = grammarForExtension(path.extname(file.relativePath))!; @@ -68,12 +74,12 @@ export function walkFile(file: CodeCollectedFile): FileWalkResult { tree = parser.parse(file.content); } catch (error) { parseErrors.push(`parse failed: ${file.relativePath}: ${error instanceof Error ? error.message : String(error)}`); - return { symbols, swiftModuleSymbols, swiftMemberSymbols, imports, callSites, implementsSites, parseErrors }; + return { symbols, swiftModuleSymbols, swiftMemberNames, imports, callSites, implementsSites, parseErrors }; } if (!tree) { parseErrors.push(`parse returned null: ${file.relativePath}`); - return { symbols, swiftModuleSymbols, swiftMemberSymbols, imports, callSites, implementsSites, parseErrors }; + return { symbols, swiftModuleSymbols, swiftMemberNames, imports, callSites, implementsSites, parseErrors }; } try { @@ -83,6 +89,13 @@ export function walkFile(file: CodeCollectedFile): FileWalkResult { // call would re-walk the enclosing declaration once for each of its calls. const swiftShadowedNames = variant === "swift" ? buildSwiftShadowedNames(tree.rootNode) : undefined; + if (variant === "swift") { + const names = new Set(); + collectSwiftMemberNames(tree.rootNode, names); + for (const name of names) { + swiftMemberNames.push({ file: file.relativePath, name }); + } + } for (const match of query.matches(tree.rootNode)) { const byName = new Map(match.captures.map((c) => [c.name, c.node])); @@ -129,12 +142,8 @@ export function walkFile(file: CodeCollectedFile): FileWalkResult { symbols.push(symbol); // Swift files in one module see each other without any import, so the // module index needs exactly the declarations a sibling can reach. - if (variant === "swift") { - if (isSwiftModuleVisible(decl)) { - swiftModuleSymbols.push(symbol); - } else if (isSwiftTypeMember(decl)) { - swiftMemberSymbols.push(symbol); - } + if (variant === "swift" && isSwiftModuleVisible(decl)) { + swiftModuleSymbols.push(symbol); } continue; } @@ -179,7 +188,7 @@ export function walkFile(file: CodeCollectedFile): FileWalkResult { tree.delete(); } - return { symbols, swiftModuleSymbols, swiftMemberSymbols, imports, callSites, implementsSites, parseErrors }; + return { symbols, swiftModuleSymbols, swiftMemberNames, imports, callSites, implementsSites, parseErrors }; } /** @@ -224,20 +233,56 @@ function isSwiftModuleVisible(decl: Node): boolean { } /** - * Whether this declaration is a member of a type. - * * tree-sitter-swift puts the members of a class, a struct, an actor or an * `extension` in a `class_body`, an enum's in an `enum_class_body` and a - * protocol's requirements in a `protocol_body`, so the parent node answers this - * on its own. + * protocol's requirements in a `protocol_body`. + */ +const SWIFT_TYPE_BODIES = new Set(["class_body", "enum_class_body", "protocol_body"]); + +/** + * Every name the types in a file declare as a member. + * + * Read off the tree rather than off the symbols the query captures, because the + * query captures functions and types only: a `let work: () -> Int` is called as + * `work()` exactly like a `func work()`, and so is a `var` holding a closure, so + * a set built from function captures alone still lets the module-level fallback + * claim a bare call that one of them answers. + * + * A declaration names itself one of two ways, and both are taken here: + * + * - a `name` field holding an identifier — a method, a nested type, a + * `typealias`, an `init`, an enum case; + * - a `pattern` child — how `property_declaration` and + * `protocol_property_declaration` carry their name, including the several + * names of `let (a, b) = ...`. Descending the pattern is what + * `collectSwiftShadowedNames` already does for a local binding. + * + * A `subscript_declaration` has neither (its `name` field is the return type) and + * is not reachable by a bare name anyway, so it contributes nothing. * * A declaration inside a function body is deliberately not a member: it sits - * under `statements`, and nothing outside that body can be referring to it. The - * enclosing-scope bindings on the call site already cover that case. + * under `statements`, not under a type body, and nothing outside that body can + * be referring to it. The enclosing-scope bindings on the call site already + * cover that case. */ -function isSwiftTypeMember(decl: Node): boolean { - const parent = decl.parent?.type; - return parent === "class_body" || parent === "enum_class_body" || parent === "protocol_body"; +function collectSwiftMemberNames(node: Node, names: Set): void { + if (SWIFT_TYPE_BODIES.has(node.type)) { + for (const member of namedChildrenOf(node)) { + const name = member.childForFieldName("name"); + if (name && (name.type === "simple_identifier" || name.type === "type_identifier")) { + addSwiftName(name.text, names); + continue; + } + for (const child of namedChildrenOf(member)) { + if (child.type === "pattern") { + collectSwiftShadowedNames(child, names); + } + } + } + } + for (const child of namedChildrenOf(node)) { + collectSwiftMemberNames(child, names); + } } function symbolId(file: string, kind: AstSymbolKind, name: string): string {