From a578747455f377f2073178c38c35189990cd7026 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Sat, 19 Sep 2026 09:46:53 +0100 Subject: [PATCH] fix(swift): resolve nested constructors in extensions (#3308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(swift): resolve nested constructors in extensions * fix(swift): preserve qualified extension owners * Address PR review feedback (#3308) Recover qualified Swift extension owners through public / attribute prefixes, and stop last-dot-guessing when source text is present. Co-authored-by: Cursor * fix(swift): keep attribute text from stealing extension owners Bound header recovery so @available messages cannot rekey a fragment, and still inject nested types when the Class scope has no bindings. Co-authored-by: Cursor * fix(swift): nest comments and keep the public extension fixture valid Review follow-up: skip nested /* */ in the header scan, put the Inner.Entry decoy in a parsed file, and mark Outer/Container/Entry public so the live fixture is valid Swift. Co-authored-by: Cursor * fix(swift): recover Unicode identifiers as extension owners The header regex was ASCII-only, so extension Café.Container keyed as Caf and dropped nested-type siblings. Match ID_Start/ID_Continue segments instead. Co-authored-by: Cursor * fix(swift): skip raw strings and decode UTF-8 scope columns Header recovery treated #"..."# as an ordinary quote and sliced Tree-sitter byte columns as JS offsets, so a same-line Café prefix or a raw attribute message could steal or drop the extension owner. Co-authored-by: Cursor --------- Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- gitnexus/bench/scope-capture/baselines.json | 8 +- .../languages/swift/target-siblings.ts | 329 ++++++++++++++- .../Builder.swift | 5 + .../Standalone.swift | 3 + .../Types.swift | 8 + .../expected-captures.json | 12 + .../test/integration/resolvers/swift.test.ts | 30 ++ .../swift/target-siblings.test.ts | 391 ++++++++++++++++++ 8 files changed, 775 insertions(+), 11 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Builder.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Standalone.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Types.swift create mode 100644 gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 74e4588e9..c715f59b7 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -117,7 +117,7 @@ "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57." }, "swift": { - "fingerprint": "724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329", + "fingerprint": "decf74c01af0c7f403e203b19c2bd92dbf95b4562f6da2b0ac5117ef872204da", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725 -> 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d; scaling 1.042 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.", @@ -126,8 +126,12 @@ "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.", "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.", "_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5.", + "_rebaselined_nested_constructor_3262": "#3262: the existing nested-constructor fixture now uses the qualified legal form `extension Outer.Container`, covering full extension-owner recovery. Fixture-only capture drift; no capture logic changed. Prior adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9 -> 3ac80b64776969effb75c8d32aa0bc38fc6a41c6c115a33829c502559fa404f9; scaling 1.025 < 1.5.", "_rebaselined_3309_call_result_assignment": "#3309: the Swift protocol-extension regression fixture adds one file with an untyped local initialized from a helper call, plus a compound-field receiver call. The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1235 -> 1272 and fixture_count 62 -> 64; synthetic scale counts remain 5012/16012. Prior adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9 -> 4d32535d454cd79086f0fcaec3b75de855c1314e73630321460e569f19aa484f; measured scaling 1.020 < 1.5.", - "_rebaselined_3309_exact_return_replay": "#3309 follow-up: Swift callable declarations now emit exact return-type captures, and untyped local initializers emit call-result assignment captures through direct, try, await, and try-await forms. This is intentional capture-set growth covered by exact-callable collision and wrapper regressions. Prior 4d32535d454cd79086f0fcaec3b75de855c1314e73630321460e569f19aa484f -> 724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329; hosted benchmark scaling remained within the existing 1.5 budget." + "_rebaselined_3309_exact_return_replay": "#3309 follow-up: Swift callable declarations now emit exact return-type captures, and untyped local initializers emit call-result assignment captures through direct, try, await, and try-await forms. This is intentional capture-set growth covered by exact-callable collision and wrapper regressions. Prior 4d32535d454cd79086f0fcaec3b75de855c1314e73630321460e569f19aa484f -> 724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329; hosted benchmark scaling remained within the existing 1.5 budget.", + "_rebaselined_3308_merge_main": "Merge origin/main into #3308: combine #3262 fixture-owner recovery with #3309 protocol-extension capture growth. Prior 3ac80b64776969effb75c8d32aa0bc38fc6a41c6c115a33829c502559fa404f9 + 724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329 -> b0c89f8de1a4ce30409182e79c1c14cbc49a509d18d853c0d7c5f22f1f66be06; capture_groups_fp 1297, fixture_count 67.", + "_rebaselined_3308_public_extension": "#3308 review: the nested-constructor fixture now uses `public extension Outer.Container` so owner recovery is gated on modifier-prefixed headers. Fixture-only digest drift. Prior b0c89f8de1a4ce30409182e79c1c14cbc49a509d18d853c0d7c5f22f1f66be06 -> a6d61e3749c9c02b9a54611e42d03db02f8fabfded3d7ce53a1b7229d3d38d59; capture_groups_fp 1297, fixture_count 67.", + "_rebaselined_3308_public_enclosing_types": "#3308 review: `Outer` / `Container` / `Entry` in the nested-constructor fixture are now `public` so the `public extension` is valid Swift. Fixture-only digest drift (Types.swift capture text). Prior a6d61e3749c9c02b9a54611e42d03db02f8fabfded3d7ce53a1b7229d3d38d59 -> decf74c01af0c7f403e203b19c2bd92dbf95b4562f6da2b0ac5117ef872204da; capture_groups_fp 1297." }, "dart": { "fingerprint": "3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687", diff --git a/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts b/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts index 47ccc4109..6cf7c858b 100644 --- a/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts @@ -23,8 +23,9 @@ * finalize and must not be mutated. */ -import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { isClassLike } from '../../scope-resolution/scope/walkers.js'; import { coerceSwiftTargets, groupSwiftFilesBySpmTarget } from './target-grouping.js'; export function populateSwiftTargetSiblings( @@ -35,8 +36,6 @@ export function populateSwiftTargetSiblings( readonly resolutionConfig?: unknown; }, ): void { - // Group files by SPM target subtree (the module). No-source-dir → all - // files in one `__default__` bucket. const targets = coerceSwiftTargets(ctx.resolutionConfig); const filesByTarget = groupSwiftFilesBySpmTarget( parsedFiles, @@ -47,7 +46,8 @@ export function populateSwiftTargetSiblings( const augmentations = indexes.bindingAugmentations as Map>; for (const [, group] of filesByTarget) { - if (group.length < 2) continue; // no siblings to share + populateNestedTypeFragments(group, indexes, augmentations, ctx.fileContents); + if (group.length < 2) continue; // no file siblings to share const siblings = group.map((parsed) => ({ filePath: parsed.filePath, defs: [...parsed.localDefs] as SymbolDefinition[], @@ -59,17 +59,328 @@ export function populateSwiftTargetSiblings( if (receiverModule === undefined) continue; for (const def of target.defs) { - const name = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; - if (name === '') continue; - const bucket = getAugmentationBucket(augmentations, receiverModule, name); - if (bucket.some((b) => b.def.nodeId === def.nodeId)) continue; - bucket.push({ def, origin: 'namespace' }); + addNamespaceBinding(augmentations, receiverModule, def); } } } } } +/** + * A Swift extension is a second lexical fragment of its extended type. Make + * nested types declared by the primary fragment visible from every same-target + * fragment with the same logical owner. Keeping this on class scopes preserves + * lexical precedence when an unrelated top-level type has the same simple name. + */ +function populateNestedTypeFragments( + group: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, + augmentations: Map>, + fileContents: ReadonlyMap, +): void { + const scopesByOwner = new Map(); + const lineStartsByFile = new Map(); + for (const parsed of group) { + const source = fileContents.get(parsed.filePath); + let lineStarts: readonly number[] | undefined; + if (source !== undefined) { + lineStarts = lineStartsByFile.get(parsed.filePath); + if (lineStarts === undefined) { + lineStarts = lineStartsOf(source); + lineStartsByFile.set(parsed.filePath, lineStarts); + } + } + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + const key = scopeOwnerKey(scope, source, lineStarts); + if (key === undefined) continue; + let scopes = scopesByOwner.get(key); + if (scopes === undefined) { + scopes = []; + scopesByOwner.set(key, scopes); + } + scopes.push(scope.id); + } + } + + for (const parsed of group) { + for (const def of parsed.localDefs) { + if (!isClassLike(def.type) || def.ownerId === undefined) continue; + const owner = indexes.defs.byId.get(def.ownerId); + if (owner === undefined) continue; + const targetScopes = scopesByOwner.get(logicalOwnerKey(owner)); + if (targetScopes === undefined) continue; + for (const scopeId of targetScopes) { + addNamespaceBinding(augmentations, scopeId, def); + } + } + } +} + +function scopeOwnerKey( + scope: Scope, + source: string | undefined, + lineStarts?: readonly number[], +): string | undefined { + const owner = scope.ownedDefs.find((def) => isClassLike(def.type)); + if (owner !== undefined) return logicalOwnerKey(owner); + + // Extension scopes carry no synthetic class def. Capture generation keeps + // only the trailing owner on members (`Inner.f` for `extension Outer.Inner`), + // so recover the full owner from this scope's declaration text first. + if (source !== undefined) { + const sourceOwner = swiftExtensionOwner(source, scope, lineStarts); + // Do not last-dot-guess: member qualified names are trailing-only, so + // `Inner.make` would key `Inner` instead of `Outer.Inner`. + if (sourceOwner === undefined) return undefined; + const representative = firstBoundDefinition(scope); + return logicalOwnerKey({ + ...(representative ?? { + nodeId: sourceOwner, + filePath: scope.filePath, + type: 'Class', + qualifiedName: sourceOwner, + }), + qualifiedName: sourceOwner, + }); + } + + // Hand-built fixtures and old cached shapes may have no source text. Keep + // the conservative member-prefix fallback, rejecting inconsistent owners. + let inferredOwner: string | undefined; + for (const refs of scope.bindings.values()) { + for (const { def } of refs) { + const qualifiedName = def.qualifiedName; + if (qualifiedName === undefined) continue; + const separator = qualifiedName.lastIndexOf('.'); + if (separator <= 0) continue; + const candidate = logicalOwnerKey({ + ...def, + qualifiedName: qualifiedName.slice(0, separator), + }); + if (inferredOwner !== undefined && inferredOwner !== candidate) return undefined; + inferredOwner = candidate; + } + } + return inferredOwner; +} + +function firstBoundDefinition(scope: Scope): SymbolDefinition | undefined { + for (const refs of scope.bindings.values()) { + const first = refs[0]?.def; + if (first !== undefined) return first; + } + return undefined; +} + +/** + * Read `extension Outer.Inner` from the class-scope source range. + * Access modifiers and attributes (`public`, `@MainActor`, `@available`) + * may precede the keyword, so the match is not start-anchored. Attribute + * message strings and comments must not supply a false `extension Type`. + * Owner segments use Unicode identifier characters so `Café.Container` + * is not truncated to `Caf`. + */ +const SWIFT_TYPE_IDENT = String.raw`[\p{ID_Start}_][\p{ID_Continue}]*`; +const EXTENSION_OWNER = new RegExp( + String.raw`\bextension\s+(${SWIFT_TYPE_IDENT}(?:\s*\.\s*${SWIFT_TYPE_IDENT})*)`, + 'u', +); + +function swiftExtensionOwner( + source: string, + scope: Scope, + lineStarts?: readonly number[], +): string | undefined { + const declaration = sliceScopeRange(source, scope.range, lineStarts ?? lineStartsOf(source)); + if (declaration === undefined) return undefined; + const cleaned = cleanExtensionHeader(declaration); + return EXTENSION_OWNER.exec(cleaned)?.[1]?.replace(/\s+/g, ''); +} + +/** `Scope.range` is 1-based on lines. Columns are Tree-sitter UTF-8 bytes. */ +function lineStartsOf(source: string): number[] { + const starts = [0, 0]; + for (let index = 0; index < source.length; index += 1) { + if (source[index] === '\n') starts.push(index + 1); + } + return starts; +} + +function sliceScopeRange( + source: string, + range: Scope['range'], + starts: readonly number[], +): string | undefined { + const start = starts[range.startLine]; + const end = starts[range.endLine]; + if (start === undefined || end === undefined) return undefined; + return source.slice( + start + jsOffsetForUtf8Column(source, start, range.startCol), + end + jsOffsetForUtf8Column(source, end, range.endCol), + ); +} + +/** Convert a Tree-sitter UTF-8 column into a JS string offset on that line. */ +function jsOffsetForUtf8Column(source: string, lineStart: number, utf8Column: number): number { + let bytes = 0; + let index = lineStart; + while (index < source.length && bytes < utf8Column) { + if (source[index] === '\n') break; + const codePoint = source.codePointAt(index); + if (codePoint === undefined) break; + bytes += utf8ByteLength(codePoint); + index += codePoint > 0xffff ? 2 : 1; + } + return index - lineStart; +} + +function utf8ByteLength(codePoint: number): number { + if (codePoint <= 0x7f) return 1; + if (codePoint <= 0x7ff) return 2; + if (codePoint <= 0xffff) return 3; + return 4; +} + +/** Header through the first unquoted `{`, with strings and comments blanked. */ +function cleanExtensionHeader(declaration: string): string { + let index = 0; + let cleaned = ''; + const blank = (from: number, to: number): void => { + for (let cursor = from; cursor < to; cursor += 1) { + cleaned += declaration[cursor] === '\n' ? '\n' : ' '; + } + }; + while (index < declaration.length) { + const current = declaration[index]; + if (current === '/' && declaration[index + 1] === '/') { + const end = skipLineComment(declaration, index); + blank(index, end); + index = end; + continue; + } + if (current === '/' && declaration[index + 1] === '*') { + const end = skipBlockComment(declaration, index); + blank(index, end); + index = end; + continue; + } + const pounds = current === '#' ? leadingPounds(declaration, index) : 0; + const quoteAt = index + pounds; + if (declaration.startsWith('"""', quoteAt) || declaration[quoteAt] === '"') { + const end = skipSwiftString(declaration, index); + blank(index, end); + index = end; + continue; + } + if (current === "'") { + const end = skipQuoted(declaration, index, current); + blank(index, end); + index = end; + continue; + } + if (current === '{') break; + cleaned += current; + index += 1; + } + return cleaned; +} + +function skipLineComment(text: string, start: number): number { + const newline = text.indexOf('\n', start); + return newline === -1 ? text.length : newline + 1; +} + +function skipBlockComment(text: string, start: number): number { + let index = start + 2; + let depth = 1; + while (index < text.length && depth > 0) { + if (text.startsWith('/*', index)) { + depth += 1; + index += 2; + continue; + } + if (text.startsWith('*/', index)) { + depth -= 1; + index += 2; + continue; + } + index += 1; + } + return index; +} + +function leadingPounds(text: string, start: number): number { + let count = 0; + while (text[start + count] === '#') count += 1; + return count; +} + +function skipSwiftString(text: string, start: number): number { + const pounds = leadingPounds(text, start); + const quoteAt = start + pounds; + if (text.startsWith('"""', quoteAt)) { + return skipDelimitedString(text, quoteAt + 3, `"""${'#'.repeat(pounds)}`, pounds === 0); + } + if (text[quoteAt] === '"') { + return skipDelimitedString(text, quoteAt + 1, `"${'#'.repeat(pounds)}`, pounds === 0); + } + return start + Math.max(pounds, 1); +} + +function skipDelimitedString( + text: string, + bodyStart: number, + closer: string, + escapes: boolean, +): number { + let index = bodyStart; + while (index < text.length) { + if (escapes && text[index] === '\\') { + index += 2; + continue; + } + if (text.startsWith(closer, index)) return index + closer.length; + index += 1; + } + return text.length; +} + +function skipQuoted(text: string, start: number, quote: string): number { + let index = start + 1; + while (index < text.length) { + if (text[index] === '\\') { + index += 2; + continue; + } + if (text[index] === quote) return index + 1; + index += 1; + } + return text.length; +} + +function logicalOwnerKey(def: SymbolDefinition): string { + const qualifiedName = def.qualifiedName ?? def.nodeId; + const namespacePrefix = def.namespacePrefix ?? ''; + return `${namespacePrefix.length}:${namespacePrefix}:${qualifiedName}`; +} + +function simpleName(def: SymbolDefinition): string { + return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; +} + +function addNamespaceBinding( + augmentations: Map>, + scopeId: ScopeId, + def: SymbolDefinition, +): void { + const name = simpleName(def); + if (name === '') return; + const bucket = getAugmentationBucket(augmentations, scopeId, name); + if (bucket.some((binding) => binding.def.nodeId === def.nodeId)) return; + bucket.push({ def, origin: 'namespace' }); +} + function getAugmentationBucket( augmentations: Map>, scopeId: ScopeId, diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Builder.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Builder.swift new file mode 100644 index 000000000..5b15f19cb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Builder.swift @@ -0,0 +1,5 @@ +public extension Outer.Container { + static func makeEntry() -> Entry { + Entry(id: 1, text: "sample") + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Standalone.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Standalone.swift new file mode 100644 index 000000000..7f0d964f0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Standalone.swift @@ -0,0 +1,3 @@ +struct Entry { + let enabled: Bool +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Types.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Types.swift new file mode 100644 index 000000000..bd7f0806d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Types.swift @@ -0,0 +1,8 @@ +public enum Outer { + public struct Container { + public struct Entry { + let id: Int + let text: String + } + } +} diff --git a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json index c7ccea7ee..1d67db04f 100644 --- a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json @@ -171,6 +171,18 @@ "captureGroups": 8, "digest": "469f895fc72297a08eb0c7ddbf7fdbb812c9296076b4797cd9262e2b7ab59a94" }, + "swift-nested-constructor-extension/Builder.swift": { + "captureGroups": 7, + "digest": "8e4e01966ad669ef2e8ea6fc032053540588507e75ecabe142ab5f0b35211cb2" + }, + "swift-nested-constructor-extension/Standalone.swift": { + "captureGroups": 5, + "digest": "4ac71fb5f08cea3ab32a23ae41dd3bf3a215880cbb826b83d1c7427fa837d3c2" + }, + "swift-nested-constructor-extension/Types.swift": { + "captureGroups": 11, + "digest": "489630892c43db4b9440e6de387d91f3b84ed82702cfffab11db2e6afcce5da1" + }, "swift-nested-extension/Extension.swift": { "captureGroups": 7, "digest": "c69b554d6a1372a449198db7581a2713a1850bded67874101d3cf2faa784f646" diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index 568cf3346..9805abf28 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -1297,6 +1297,36 @@ describe.skipIf(!swiftAvailable)('Swift nested-type extension (extension Foo.Bar }); }); +// --------------------------------------------------------------------------- +// A bare constructor inside an extension must prefer a nested type owned by +// the extended type over an unrelated top-level type with the same short name. +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)( + 'Swift nested constructor lookup in a public qualified extension (#3262)', + () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-nested-constructor-extension'), + () => {}, + ); + }, 60000); + + it('resolves Entry(id:text:) to Outer.Container.Entry and not the top-level Entry', () => { + const entryCalls = getRelationships(result, 'CALLS').filter( + (call) => call.source === 'makeEntry' && call.target === 'Entry', + ); + + expect(entryCalls.map((call) => call.rel.targetId)).toEqual(['Struct:Types.swift:Entry']); + expect(entryCalls.some((call) => call.rel.targetId === 'Struct:Standalone.swift:Entry')).toBe( + false, + ); + }); + }, +); + // --------------------------------------------------------------------------- // F75: protocol property requirements (`var title: String { get }`) are // extracted as Property symbols owned by the protocol. Before the fix these diff --git a/gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts b/gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts new file mode 100644 index 000000000..eef725014 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts @@ -0,0 +1,391 @@ +import { + buildDefIndex, + type ParsedFile, + type ScopeId, + type SymbolDefinition, +} from 'gitnexus-shared'; +import { describe, expect, it } from 'vitest'; +import { populateSwiftTargetSiblings } from '../../../../src/core/ingestion/languages/swift/target-siblings.js'; +import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js'; + +const moduleId = (filePath: string) => `scope:${filePath}:module` as ScopeId; +const classId = (filePath: string) => `scope:${filePath}:class` as ScopeId; + +function parsedFile( + filePath: string, + classOwnedDefs: readonly SymbolDefinition[], + classBindings: ReadonlyMap, + localDefs: readonly SymbolDefinition[], + classRange = { startLine: 1, startCol: 0, endLine: 10, endCol: 0 }, +): ParsedFile { + return { + filePath, + moduleScope: moduleId(filePath), + scopes: [ + { + id: moduleId(filePath), + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 10, endCol: 0 }, + filePath, + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map(), + }, + { + id: classId(filePath), + parent: moduleId(filePath), + kind: 'Class', + range: classRange, + filePath, + bindings: classBindings, + ownedDefs: classOwnedDefs, + imports: [], + typeBindings: new Map(), + }, + ], + parsedImports: [], + localDefs, + referenceSites: [], + }; +} + +describe('Swift target sibling visibility', () => { + it('binds a nested type into a same-target extension fragment', () => { + const container: SymbolDefinition = { + nodeId: 'def:Types.swift:Container', + filePath: 'Types.swift', + type: 'Class', + qualifiedName: 'Container', + }; + const entry: SymbolDefinition = { + nodeId: 'def:Types.swift:Container.Entry', + filePath: 'Types.swift', + type: 'Class', + qualifiedName: 'Container.Entry', + ownerId: container.nodeId, + }; + const makeEntry: SymbolDefinition = { + nodeId: 'def:Builder.swift:Container.makeEntry', + filePath: 'Builder.swift', + type: 'Method', + qualifiedName: 'Container.makeEntry', + }; + const declaration = parsedFile( + 'Types.swift', + [container], + new Map([['Entry', [{ def: entry, origin: 'local' }]]]), + [container, entry], + ); + const extension = parsedFile( + 'Builder.swift', + [], + new Map([['makeEntry', [{ def: makeEntry, origin: 'local' }]]]), + [makeEntry], + ); + const bindingAugmentations = new Map(); + const indexes = makeIndexes([container, entry, makeEntry], bindingAugmentations); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map(), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it('recovers Outer.Container from public extension source when members are trailing-only', () => { + const extensionSource = 'public extension Outer.Container {\n static func makeEntry() {}\n}\n'; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { ownerQualifiedName: 'Outer.Container' }); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it('recovers a qualified owner when the extension Class scope has no bindings', () => { + const extensionSource = 'public extension Outer.Inner {\n subscript(i: Int) -> Int { i }\n}\n'; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { classBindings: new Map() }); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it('does not infer an extension owner from inconsistent qualified members', () => { + const container: SymbolDefinition = { + nodeId: 'def:Types.swift:Container', + filePath: 'Types.swift', + type: 'Class', + qualifiedName: 'Container', + }; + const entry: SymbolDefinition = { + nodeId: 'def:Types.swift:Container.Entry', + filePath: 'Types.swift', + type: 'Class', + qualifiedName: 'Container.Entry', + ownerId: container.nodeId, + }; + const containerMethod: SymbolDefinition = { + nodeId: 'def:Builder.swift:Container.makeEntry', + filePath: 'Builder.swift', + type: 'Method', + qualifiedName: 'Container.makeEntry', + }; + const otherMethod: SymbolDefinition = { + nodeId: 'def:Builder.swift:Other.makeEntry', + filePath: 'Builder.swift', + type: 'Method', + qualifiedName: 'Other.makeEntry', + }; + const declaration = parsedFile( + 'Types.swift', + [container], + new Map([['Entry', [{ def: entry, origin: 'local' }]]]), + [container, entry], + ); + const ambiguousExtension = parsedFile( + 'Builder.swift', + [], + new Map([ + ['containerMethod', [{ def: containerMethod, origin: 'local' }]], + ['otherMethod', [{ def: otherMethod, origin: 'local' }]], + ]), + [containerMethod, otherMethod], + ); + const bindingAugmentations = new Map(); + const indexes = makeIndexes( + [container, entry, containerMethod, otherMethod], + bindingAugmentations, + ); + + populateSwiftTargetSiblings([declaration, ambiguousExtension], indexes, { + fileContents: new Map(), + }); + + expect(bindingAugmentations.get(classId('Builder.swift'))?.get('Entry')).toBeUndefined(); + }); + + it('preserves the qualified owner of a nested-type extension', () => { + // Capture generation keeps only the trailing owner on members (`Inner.f`), + // so source text must recover `Outer.Inner`. + const extensionSource = 'extension Outer.Inner {\n static func makeEntry() {}\n}\n'; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it.each([ + ['public extension Outer.Inner {\n static func makeEntry() {}\n}\n', 0], + ['@MainActor\nextension Outer.Inner {\n static func makeEntry() {}\n}\n', 0], + ['@available(iOS 15, *)\npublic extension Outer.Inner {\n static func makeEntry() {}\n}\n', 0], + [ + '@available(*, deprecated, message: "Use extension Other.Inner")\npublic extension Outer.Inner {\n static func makeEntry() {}\n}\n', + 0, + ], + [' public extension Outer.Inner {\n static func makeEntry() {}\n}\n', 2], + [ + '/* outer /* inner */ extension Wrong */ extension Outer.Inner {\n static func makeEntry() {}\n}\n', + 0, + ], + [ + '@available(*, deprecated, message: #"Use extension Wrong"#)\npublic extension Outer.Inner {\n static func makeEntry() {}\n}\n', + 0, + ], + ])( + 'recovers a qualified owner through modifiers and attributes: %j', + (extensionSource, startCol = 0) => { + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { startCol }); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }, + ); + + it('converts Tree-sitter UTF-8 columns before slicing JS source', () => { + const prefix = 'struct Café {}; '; + const extensionSource = `${prefix}extension Outer.Inner {\n static func makeEntry() {}\n}\n`; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { + startCol: Buffer.byteLength(prefix, 'utf8'), + }); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it('recovers a Unicode qualified extension owner', () => { + const extensionSource = 'public extension Café.Container {\n static func makeEntry() {}\n}\n'; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { ownerQualifiedName: 'Café.Container' }); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it('prefers Outer.Inner.Entry over a colliding top-level Inner.Entry', () => { + const { collision, topInner, wrongEntry } = collidingTopLevelInner(); + const extensionSource = 'public extension Outer.Inner {\n static func makeEntry() {}\n}\n'; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { extraDefs: [topInner, wrongEntry] }); + + populateSwiftTargetSiblings([declaration, extension, collision], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + expect(bindingAugmentations.get(classId('TopInner.swift'))?.get('Entry')).toEqual([ + { def: wrongEntry, origin: 'namespace' }, + ]); + }); + + it('does not last-dot-guess Inner when source is present but not an extension', () => { + const { collision, topInner, wrongEntry } = collidingTopLevelInner(); + const { declaration, extension, indexes, bindingAugmentations } = qualifiedExtensionFixture( + 'struct Unrelated {}\n', + { extraDefs: [topInner, wrongEntry] }, + ); + + populateSwiftTargetSiblings([declaration, extension, collision], indexes, { + fileContents: new Map([['Builder.swift', 'struct Unrelated {}\n']]), + }); + + expect(bindingAugmentations.get(classId('Builder.swift'))?.get('Entry')).toBeUndefined(); + expect(bindingAugmentations.get(classId('TopInner.swift'))?.get('Entry')).toEqual([ + { def: wrongEntry, origin: 'namespace' }, + ]); + }); +}); + +function collidingTopLevelInner() { + const topInner: SymbolDefinition = { + nodeId: 'def:TopInner.swift:Inner', + filePath: 'TopInner.swift', + type: 'Class', + qualifiedName: 'Inner', + }; + const wrongEntry: SymbolDefinition = { + nodeId: 'def:TopInner.swift:Inner.Entry', + filePath: 'TopInner.swift', + type: 'Class', + qualifiedName: 'Inner.Entry', + ownerId: topInner.nodeId, + }; + const collision = parsedFile( + 'TopInner.swift', + [topInner], + new Map([['Entry', [{ def: wrongEntry, origin: 'local' }]]]), + [topInner, wrongEntry], + ); + return { collision, topInner, wrongEntry }; +} + +function expectAugmentedEntry( + bindingAugmentations: Map>, + entry: SymbolDefinition, +) { + expect(bindingAugmentations.get(classId('Builder.swift'))?.get('Entry')).toEqual([ + { def: entry, origin: 'namespace' }, + ]); +} + +function makeIndexes( + defs: readonly SymbolDefinition[], + bindingAugmentations: Map, +): ScopeResolutionIndexes { + return { + defs: buildDefIndex(defs), + moduleScopes: { + byFilePath: new Map([ + ['Types.swift', moduleId('Types.swift')], + ['Builder.swift', moduleId('Builder.swift')], + ]), + }, + bindingAugmentations, + } as unknown as ScopeResolutionIndexes; +} + +function rangeForSource(source: string, startCol = 0) { + const lines = source.split('\n'); + const last = source.endsWith('\n') ? lines.length - 2 : lines.length - 1; + const lastLine = Math.max(last, 0); + return { + startLine: 1, + startCol, + endLine: lastLine + 1, + endCol: (lines[lastLine] ?? '').length, + }; +} + +function qualifiedExtensionFixture( + extensionSource: string, + options: { + extraDefs?: readonly SymbolDefinition[]; + classBindings?: ReadonlyMap; + startCol?: number; + ownerQualifiedName?: string; + } = {}, +) { + const ownerQualifiedName = options.ownerQualifiedName ?? 'Outer.Inner'; + const ownerSimple = ownerQualifiedName.split('.').pop() ?? ownerQualifiedName; + const owner: SymbolDefinition = { + nodeId: `def:Types.swift:${ownerQualifiedName}`, + filePath: 'Types.swift', + type: 'Class', + qualifiedName: ownerQualifiedName, + }; + const entry: SymbolDefinition = { + nodeId: `def:Types.swift:${ownerQualifiedName}.Entry`, + filePath: 'Types.swift', + type: 'Class', + qualifiedName: `${ownerQualifiedName}.Entry`, + ownerId: owner.nodeId, + }; + const makeEntry: SymbolDefinition = { + nodeId: `def:Builder.swift:${ownerSimple}.makeEntry`, + filePath: 'Builder.swift', + type: 'Method', + qualifiedName: `${ownerSimple}.makeEntry`, + }; + const extraDefs = options.extraDefs ?? []; + const classBindings = + options.classBindings ?? + new Map([['makeEntry', [{ def: makeEntry, origin: 'local' as const }]]]); + const declaration = parsedFile( + 'Types.swift', + [owner], + new Map([['Entry', [{ def: entry, origin: 'local' }]]]), + [owner, entry], + ); + const extension = parsedFile( + 'Builder.swift', + [], + classBindings, + [makeEntry], + rangeForSource(extensionSource, options.startCol ?? 0), + ); + const bindingAugmentations = new Map(); + const indexes = makeIndexes([owner, entry, makeEntry, ...extraDefs], bindingAugmentations); + return { declaration, extension, entry, indexes, bindingAugmentations }; +}