Files
codegraph/__tests__/swift-composition-continuation.test.ts
Colby Mchenry e9be68b61d 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>
2026-09-29 21:43:56 +00:00

133 lines
4.8 KiB
TypeScript

/**
* 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();
}
});
});