fix(swift): a composition continued on & lines no longer swallows its type (#2108)

typealias EditorClient = AutocompleteService.Client
      & MediaUploadService.Client

is valid Swift, but in a type's body tree-sitter-swift ends the member at
the newline and the `&` line is an ERROR that swallows the enclosing
type. IceCubesApp's 980-line `EditorStore` came out as loose variables
and functions — no class, no init, no methods — so a call in its init
counted as file-level code ("a file that runs something"), and calls
into its methods from the views landed on same-named methods of other
services.

A Swift preParse moves a line-leading `&` onto the line before, for an
indented `typealias`/`let`/`var` statement's continuation lines only.
Same length and line count; the continuation line's tokens sit one
column left (the Swift type gate reads the site's qualifier at that one
column over). Measured shapes: the failure is type bodies only — a
top-level declaration, a function body and a parenthesized type parse as
written, and are left alone; `&&`, `&+` and inout `&x` are never
touched. The preParse is hoisted before the kernel call like the C/C#
ones, so both arms parse the same bytes, and the file no longer defers
to wasm.

Across IceCubesApp, SwiftPackageIndex-Server, swift-nio, Alamofire, TCA
and mastodon-ios (3,000 Swift files) it rewrites exactly the one failing
file, which now parses clean; none newly erroring. EditorStore is a class
again with 38 methods; views now reference it and call its
insertStatusText / runAssistant (62 edges into it), and
`urlSession.upload` no longer lands on MediaUploadService.upload.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-29 21:43:56 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5753de20f5
commit e9be68b61d
5 changed files with 206 additions and 2 deletions
+1
View File
@@ -16,6 +16,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Laravel routes are now named by the path a request takes: a leading `/` is added where the routes file leaves it out, `Route::prefix()` and `Route::group(['prefix' => …])` groups are applied, and routes in `routes/api.php` carry the `/api` prefix Laravel serves them under, read from your `RouteServiceProvider`, `bootstrap/app.php` or any file that mounts a routes file. A front-end `fetch('/api/…')` now connects to the Laravel route that serves it. Re-index Laravel projects after upgrading.
- Controllers that Spring or Laravel tests exercise by URL now count as tested. That covers MockMvc's `perform(post("/owners/new"))`, WebTestClient, TestRestTemplate, RestAssured, Laravel's `$this->postJson('api/me')`, Pest's `get('/about')`, and a project's own request helpers built on them. `codegraph_explore` now names the test suite that reaches such an endpoint, where before every one of them looked untested. Re-index Spring and Laravel projects after upgrading.
- A Swift class or struct that declares a protocol composition across lines, like `typealias Client = AutocompleteService.Client` followed by `& PostingService.Client` on the next line, is now indexed whole. That line used to break the parse of the entire enclosing type, so its methods, initializer and properties went missing, and calls into them landed on same-named methods elsewhere. Re-index Swift projects after upgrading.
- A Swift reference to a type now links to the type's own declaration, not to a file that extends it. An `extension View { … }` or `extension Text { … }` used to stand in for SwiftUI's type, so every view, every `Text("…")` and every `Color.red` in an app linked to whichever file happened to extend it. Those files topped the most-depended-on lists and their impact reached the whole app. A bare name like `@State`, `@Test` or `Result<…>` no longer links to some other type's nested `State` or `Result`, and a qualified name like `Build.Id` links to the `Id` it names. Methods declared in an extension of an SDK type still resolve on the types that conform to it, and a protocol's methods declared in any of its extensions now connect to each conforming type's own implementation. Re-index Swift projects after upgrading.
- A Vapor route now links to the handler it names. `use: SearchController.show` used to link to whichever controller's `show` came first, so routes with a common handler name, like `show`, `index` or `get`, pointed at another endpoint's code in callers, impact and `codegraph_explore` answers. Nested types like `API.PackageController.get` and handlers declared in an extension of the controller now resolve too, and `use: self.index` resolves to the collection's own `index`. Re-index Vapor projects after upgrading.
- A PHP call written without a receiver, such as `redirect($url)`, `view('books.show')`, `auth()` or `basename($path)`, is a function call, and no longer links to a same-named method, field or class elsewhere in the project. These wrong links showed up in callers, impact and `codegraph_explore` answers wherever a Laravel helper or PHP built-in shared its name with a project member. Re-index PHP projects after upgrading.
+22
View File
@@ -112,6 +112,28 @@ describe.skipIf(!kernelBuilt)('kernel Swift extraction parity', () => {
assertParity('fixtures/torture.swift (crlf)', crlf, 40);
});
// A composition's `&` line in a type body errored and swallowed the type;
// the Swift preParse moves the `&` up a line, hoisted so both arms parse the
// same bytes — and the file no longer defers.
it('a composition typealias in a type body parses on both arms, LF and CRLF', () => {
const source = [
'extension StatusEditor {',
' final class EditorStore: NSObject {',
' typealias EditorClient = AutocompleteService.Client',
' & PostingService.Client',
' & CustomEmojiService.Client',
' let service: any Uploading',
' & Describing',
' func post() -> Bool { validate() }',
' func validate() -> Bool { true }',
' }',
'}',
'',
].join('\n');
assertParity('src/EditorStore.swift', source, 5);
assertParity('src/EditorStore.swift (crlf)', source.replace(/\n/g, '\r\n'), 5);
});
it('files with parse errors defer to the wasm extractor (recovery is encoding-dependent)', () => {
// A NEW-only regression construct (`#if` between enum cases — the swift
// checklist's grammar-bump delta 5) — errors on the 0.7.3 grammar.
@@ -0,0 +1,132 @@
/**
* A protocol composition continued on `&` lines, inside a type's body.
*
* typealias EditorClient = AutocompleteService.Client
* & MediaUploadService.Client
*
* is valid Swift, but tree-sitter-swift ends the member at the newline and
* the `&` line becomes an ERROR that swallows the whole enclosing type:
* IceCubesApp's 980-line `EditorStore` class came out as loose variables and
* functions, no class and no methods.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { joinSwiftCompositionContinuations as join } from '../src/extraction/languages/swift';
describe('joinSwiftCompositionContinuations', () => {
it("moves a body declaration's line-leading & onto the line before, keeping length and lines", () => {
const src = `class Store {
typealias Client = A.Client
& B.Client
& C.Client
let service: any P
& Q
}
`;
const out = join(src);
expect(out.length).toBe(src.length);
expect(out.split('\n').length).toBe(src.split('\n').length);
expect(out).toBe(`class Store {
typealias Client = A.Client&
B.Client&
C.Client
let service: any P&
Q
}
`);
});
it('keeps CRLF line ends', () => {
const src = 'struct S {\r\n typealias X = A\r\n & B\r\n}\r\n';
expect(join(src)).toBe('struct S {\r\n typealias X = A&\r\n B\r\n}\r\n');
});
it('leaves everything else as written', () => {
for (const src of [
// A top-level declaration parses as written.
'typealias X = A\n & B\n',
// Logical and, overflow arithmetic, an inout argument: not compositions.
'func f() {\n let ok = a\n && b\n let n = x\n &+ y\n foo(\n &z)\n}\n',
// A trailing comment would swallow a moved `&`.
'class C {\n typealias X = A // the client\n & B\n}\n',
// An `&` line that continues no typealias / let / var.
'class C {\n func f() -> Int {\n mask\n & 1\n }\n}\n',
]) {
expect(join(src)).toBe(src);
}
});
});
const projects: string[] = [];
afterAll(() => {
for (const p of projects.splice(0)) fs.rmSync(p, { recursive: true, force: true });
});
describe('Swift: a type with a composition typealias is extracted whole', () => {
it('keeps the class, its methods, its init and the protocols the typealias composes', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-swift-composition-'));
projects.push(root);
const write = (rel: string, content: string) => {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
};
write(
'Sources/Editor/Services.swift',
`enum StatusEditor {}
extension StatusEditor {
enum AutocompleteService { protocol Client {} }
enum PostingService { protocol Client {} }
}
enum Assistant {
static func prewarm() {}
}
`
);
write(
'Sources/Editor/EditorStore.swift',
`extension StatusEditor {
final class EditorStore {
typealias EditorClient = AutocompleteService.Client
& PostingService.Client
var client: (any EditorClient)?
init() {
#if !targetEnvironment(macCatalyst)
Assistant.prewarm()
#endif
}
func post() -> Bool { validate() }
func validate() -> Bool { true }
}
}
`
);
const cg = await CodeGraph.init(root, { index: true });
try {
const inFile = cg.getNodesInFile('Sources/Editor/EditorStore.swift');
const store = inFile.find((n) => n.name === 'EditorStore' && n.kind === 'class');
expect(store?.qualifiedName).toBe('StatusEditor::EditorStore');
expect(store?.endLine).toBe(15);
expect(inFile.filter((n) => n.kind === 'method').map((n) => n.qualifiedName).sort()).toEqual([
'StatusEditor::EditorStore::post',
'StatusEditor::EditorStore::validate',
]);
const post = inFile.find((n) => n.name === 'post')!;
expect(cg.getCallees(post.id).map((c) => c.node.qualifiedName)).toEqual(['StatusEditor::EditorStore::validate']);
// The call inside init's #if is no longer attributed to the file.
const fileNode = inFile.find((n) => n.kind === 'file')!;
expect(cg.getCallees(fileNode.id).map((c) => c.node.qualifiedName)).not.toContain('Assistant::prewarm');
// The typealias's composed protocols, read on the rewritten `&` line one column left.
const alias = inFile.find((n) => n.name === 'EditorClient')!;
expect(cg.getCallees(alias.id).map((c) => c.node.qualifiedName).sort()).toEqual(
expect.arrayContaining(['StatusEditor::AutocompleteService::Client', 'StatusEditor::PostingService::Client'])
);
} finally {
cg.close();
}
});
});
+46
View File
@@ -40,7 +40,53 @@ function extractSwiftReturnType(node: SyntaxNode, source: string): string | unde
return undefined;
}
/**
* A declaration in a body whose type may continue onto `&` lines:
* `typealias X = A`, `let client: any A`. Indented — a top-level declaration
* parses as written, and is left alone.
*/
const COMPOSITION_HEAD = /^[ \t]+(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:[a-z]+(?:\([a-z]+\))?\s+)*(?:typealias|let|var)\s/;
const COMPOSITION_CONTINUATION = /^([ \t]*)&([ \t]+)(?=\S)/;
/**
* Move a protocol composition's line-leading `&` onto the line before it.
*
* typealias EditorClient = AutocompleteService.Client
* & MediaUploadService.Client
*
* is valid Swift, but in a type's body tree-sitter-swift ends the declaration
* at the newline and the `&` line is an ERROR that swallows the enclosing type
* (IceCubesApp's 980-line `EditorStore` came out as loose variables, no class).
* With the `&` trailing the previous line it parses. Same length and line
* count; only the continuation line's tokens sit one column left. Applied to
* an indented `typealias`/`let`/`var` statement's `&` lines only — a
* line-leading `&&`, `&+` or inout `&x` is never touched.
*/
export function joinSwiftCompositionContinuations(source: string): string {
if (!/\n[ \t]*&[ \t]/.test(source)) return source;
const original = source.split('\n');
const lines = original.slice();
let changed = false;
for (let i = 1; i < lines.length; i++) {
const cont = COMPOSITION_CONTINUATION.exec(lines[i]!);
if (!cont) continue;
let head = i - 1;
while (head > 0 && COMPOSITION_CONTINUATION.test(original[head]!)) head--;
if (!COMPOSITION_HEAD.test(original[head]!)) continue;
const prev = lines[i - 1]!;
const cr = prev.endsWith('\r') ? '\r' : '';
const body = cr ? prev.slice(0, -1) : prev;
// The previous line must end on a type; a trailing comment would swallow the `&`.
if (!/[\w>)\]?!]$/.test(body) || body.includes('//')) continue;
lines[i - 1] = `${body}&${cr}`;
lines[i] = `${cont[1]}${cont[2]}${lines[i]!.slice(cont[0].length)}`;
changed = true;
}
return changed ? lines.join('\n') : source;
}
export const swiftExtractor: LanguageExtractor = {
preParse: joinSwiftCompositionContinuations,
functionTypes: ['function_declaration'],
classTypes: ['class_declaration'],
methodTypes: ['function_declaration'], // Methods are functions inside classes
+5 -2
View File
@@ -219,8 +219,11 @@ function writtenName(ref: UnresolvedRef, context: ResolutionContext): string {
if (!text) return name;
// `EventLoopFuture<Success>.Isolated`: the qualifier's generic arguments are not part of its path.
const clean = (qualifier: string): string => `${qualifier.replace(GENERIC_ARGS, '').replace(/\s+/g, '')}${name}`;
if (text.startsWith(name, ref.column)) {
const before = QUALIFIER_BEFORE.exec(text.slice(Math.max(0, ref.column - 240), ref.column));
// A composition's `&` line is parsed one column left of the file, its `&`
// moved onto the line before (joinSwiftCompositionContinuations).
const column = !text.startsWith(name, ref.column) && /^[ \t]*&[ \t]/.test(text) && text.startsWith(name, ref.column + 1) ? ref.column + 1 : ref.column;
if (text.startsWith(name, column)) {
const before = QUALIFIER_BEFORE.exec(text.slice(Math.max(0, column - 240), column));
return before ? clean(before[1]!) : name;
}
const chain = new RegExp(String.raw`^((?:${QUALIFIER_SEGMENT})+)${name.replace(/[$\\]/g, '\\$&')}\b`).exec(text.slice(ref.column, ref.column + 240));