fix(swift): resolve nested constructors in extensions (#3308)

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
azizur100389 2026-09-19 09:46:53 +01:00 • committed by GitHub
parent 8fcc54e15f
commit a578747455
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 775 additions and 11 deletions

View file

@ -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",

View file

@ -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<ScopeId, Map<string, BindingRef[]>>;
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<ScopeId, Map<string, BindingRef[]>>,
fileContents: ReadonlyMap<string, string>,
): void {
const scopesByOwner = new Map<string, ScopeId[]>();
const lineStartsByFile = new Map<string, readonly number[]>();
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, Map<string, BindingRef[]>>,
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, Map<string, BindingRef[]>>,
scopeId: ScopeId,

View file

@ -0,0 +1,5 @@
public extension Outer.Container {
static func makeEntry() -> Entry {
Entry(id: 1, text: "sample")
}
}

View file

@ -0,0 +1,3 @@
struct Entry {
let enabled: Bool
}

View file

@ -0,0 +1,8 @@
public enum Outer {
public struct Container {
public struct Entry {
let id: Int
let text: String
}
}
}

View file

@ -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"

View file

@ -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

View file

@ -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<string, readonly { def: SymbolDefinition; origin: 'local' }[]>,
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<ScopeId, Map<string, unknown>>,
entry: SymbolDefinition,
) {
expect(bindingAugmentations.get(classId('Builder.swift'))?.get('Entry')).toEqual([
{ def: entry, origin: 'namespace' },
]);
}
function makeIndexes(
defs: readonly SymbolDefinition[],
bindingAugmentations: Map<unknown, unknown>,
): 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<string, readonly { def: SymbolDefinition; origin: 'local' }[]>;
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 };
}