mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-29 06:46:49 +08:00
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:
co-authored by
Claude Opus 5.5
parent
5753de20f5
commit
e9be68b61d
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user