mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
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:
parent
8fcc54e15f
commit
a578747455
8 changed files with 775 additions and 11 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
5
gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Builder.swift
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Builder.swift
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
public extension Outer.Container {
|
||||
static func makeEntry() -> Entry {
|
||||
Entry(id: 1, text: "sample")
|
||||
}
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Standalone.swift
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Standalone.swift
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
struct Entry {
|
||||
let enabled: Bool
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Types.swift
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Types.swift
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
public enum Outer {
|
||||
public struct Container {
|
||||
public struct Entry {
|
||||
let id: Int
|
||||
let text: String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue