Skip to content

Commit a1f961d

Browse files
committed
enhance analyzer for callee chains
1 parent 8e785ab commit a1f961d

File tree

5 files changed

+248
-21
lines changed

5 files changed

+248
-21
lines changed

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Swift AST parsing in JavaScript via WebAssembly (WASM), powered by SwiftSyntax +
99
This package compiles SwiftSyntax + SwiftParser to a WASI module and exposes a simple JavaScript API to parse Swift source into a compact JSON AST. On top of that, it provides a lightweight analyzer (inspired by Ruby Prism’s ergonomics) to:
1010

1111
- extract declarations (functions, methods, classes, structs, enums, variables)
12-
- find and inspect function/method calls
12+
- find and inspect function/method calls (with callee chain and base identifier)
1313
- follow simple identifier references within lexical scope
1414
- surface naive type hints where present (e.g., `let x: Int`)
1515

@@ -83,7 +83,13 @@ console.log([...analysis.byName.keys()]);
8383
// All calls to a function/method named "track"
8484
const calls = analysis.findCallsByName('track');
8585
for (const c of calls) {
86-
console.log({ name: c.name, receiver: c.receiver, args: c.argsCount, at: c.range.start });
86+
console.log({
87+
name: c.name,
88+
receiver: c.receiver,
89+
base: c.baseIdentifier,
90+
chain: c.calleeChain,
91+
args: analysis.getCallArgs(c.id)
92+
});
8793
}
8894

8995
// Resolve a simple name to its symbol (best-effort)
@@ -105,8 +111,8 @@ type Analysis = {
105111
```
106112
107113
Notes:
108-
- The analyzer is syntax-driven (no full type-checker). Name resolution is lexical and best-effort.
109-
- Variable type annotations (e.g., `let x: Int`) are extracted where present; inferred types are not computed.
114+
- Syntax-driven only (no full type-checker). Name resolution is lexical and best-effort.
115+
- Exposes helpers to ease tracking analysis: labeled args, dictionary extraction, enclosing symbol lookup, const string collection, and callee text/chain utilities.
110116
111117
## CLI usage
112118

src/analyze.ts

Lines changed: 210 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export type CallInfo = {
3232
argsCount: number;
3333
range: Range;
3434
refersToId?: number;
35+
calleeChain?: string[];
36+
baseIdentifier?: string;
3537
};
3638

3739
export type RefInfo = {
@@ -41,11 +43,22 @@ export type RefInfo = {
4143
refersToId?: number;
4244
};
4345

46+
export type CallArg = { label?: string; text: string; range: Range; nodeId: number };
47+
4448
export type Analysis = {
4549
symbols: Map<number, SymbolInfo>;
4650
calls: CallInfo[];
4751
refs: RefInfo[];
4852
byName: Map<string, number[]>;
53+
getNode: (id: number) => AstNode | undefined;
54+
getChildren: (id: number) => number[];
55+
getParent: (id: number) => number | undefined;
56+
getCalleeText: (callId: number) => string | undefined;
57+
getStringLiteralValue: (nodeId: number) => string | undefined;
58+
getCallArgs: (callId: number) => CallArg[];
59+
extractDictionary: (nodeId: number) => any;
60+
findEnclosing: (nodeId: number, kinds?: string[]) => SymbolInfo | undefined;
61+
collectConstStrings: () => Map<string, string>;
4962
findCallsByName: (name: string) => CallInfo[];
5063
resolveNameAt: (name: string, offset: number) => SymbolInfo | undefined;
5164
};
@@ -109,6 +122,83 @@ export function analyzeAst(ast: Ast, source: string): Analysis {
109122
const byName = new Map<string, number[]>();
110123
const calls: CallInfo[] = [];
111124
const refs: RefInfo[] = [];
125+
const constStrings = new Map<string, string>();
126+
127+
const nodeAt = (id: number) => ast.nodes[id];
128+
const kidsOf = (id: number) => children.get(id) ?? [];
129+
const parentOf = (id: number) => parent.get(id);
130+
131+
function findDirectChildToken(id: number, token: string): number | undefined {
132+
for (const k of kidsOf(id)) {
133+
const n = nodeAt(k);
134+
if (n && n.kind === 'TokenSyntax' && n.tokenText === token) return k;
135+
}
136+
return undefined;
137+
}
138+
139+
function calleeTextForCall(id: number): string | undefined {
140+
const node = nodeAt(id);
141+
if (!node) return undefined;
142+
const lparenId = findDirectChildToken(id, '(');
143+
if (!lparenId) return undefined;
144+
const lp = nodeAt(lparenId)!;
145+
const start = node.range.start.offset;
146+
const end = lp.range.start.offset; // up to the '(' that begins this call's arg list
147+
return source.slice(start, end).trim();
148+
}
149+
150+
function calleeChainParts(text: string): string[] {
151+
if (!text) return [];
152+
// Split on member separators, handling optional/force chaining
153+
return text.split(/(?:\?\.|!\.|\.)/).map(s => s.trim()).filter(Boolean);
154+
}
155+
156+
function baseIdentifierFromChain(chain: string[]): string | undefined {
157+
if (!chain.length) return undefined;
158+
const base = chain[0];
159+
return base.replace(/[!?]+$/g, '').replace(/\(\)$/, '');
160+
}
161+
162+
function splitTopLevel(input: string, sep: string): string[] {
163+
const out: string[] = [];
164+
let depthPar = 0, depthBr = 0, depthBr2 = 0;
165+
let inStr = false, esc = false;
166+
let acc = '';
167+
for (let i = 0; i < input.length; i++) {
168+
const ch = input[i];
169+
if (inStr) {
170+
acc += ch;
171+
if (esc) { esc = false; continue; }
172+
if (ch === '\\') { esc = true; continue; }
173+
if (ch === '"') { inStr = false; }
174+
continue;
175+
}
176+
if (ch === '"') { inStr = true; acc += ch; continue; }
177+
if (ch === '(') depthPar++;
178+
else if (ch === ')') depthPar = Math.max(0, depthPar - 1);
179+
else if (ch === '[') depthBr++;
180+
else if (ch === ']') depthBr = Math.max(0, depthBr - 1);
181+
else if (ch === '{') depthBr2++;
182+
else if (ch === '}') depthBr2 = Math.max(0, depthBr2 - 1);
183+
184+
if (ch === sep && depthPar === 0 && depthBr === 0 && depthBr2 === 0) {
185+
out.push(acc);
186+
acc = '';
187+
} else {
188+
acc += ch;
189+
}
190+
}
191+
if (acc.trim() !== '') out.push(acc);
192+
return out.map(s => s.trim());
193+
}
194+
195+
function unquote(s: string): string {
196+
if (s.startsWith('"') && s.endsWith('"')) {
197+
const body = s.slice(1, -1);
198+
return body.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\t/g, '\t').replace(/\\\\/g, '\\');
199+
}
200+
return s;
201+
}
112202

113203
// DFS with scope stack of maps: name -> symbolId
114204
const scopeStack: Array<Map<string, number>> = [new Map()];
@@ -158,11 +248,14 @@ export function analyzeAst(ast: Ast, source: string): Analysis {
158248

159249
// Calls and references
160250
if (kind === 'FunctionCallExprSyntax') {
161-
const text = slice(source, node.range);
162-
const c = extractCallFromText(text);
251+
const fullText = slice(source, node.range);
252+
const c = extractCallFromText(fullText);
253+
let calleeText = calleeTextForCall(id);
254+
const chain = calleeText ? calleeChainParts(calleeText) : undefined;
255+
const baseId = chain ? baseIdentifierFromChain(chain) : undefined;
163256
if (c) {
164257
const refersToId = resolveName(c.name);
165-
calls.push({ id, name: c.name, receiver: c.receiver, argsCount: c.argsCount, range: node.range, refersToId });
258+
calls.push({ id, name: c.name, receiver: c.receiver, argsCount: c.argsCount, range: node.range, refersToId, calleeChain: chain, baseIdentifier: baseId });
166259
}
167260
}
168261
if (kind === 'DeclReferenceExprSyntax') {
@@ -173,18 +266,132 @@ export function analyzeAst(ast: Ast, source: string): Analysis {
173266
refs.push({ id, name, range: node.range, refersToId });
174267
}
175268

269+
// Best-effort const strings: let NAME = "..."
270+
if (kind === 'VariableDeclSyntax') {
271+
const text = slice(source, node.range);
272+
const m = text.match(/\blet\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=]+)?=\s*("(?:[^"\\]|\\.)*")/s);
273+
if (m) {
274+
constStrings.set(m[1], unquote(m[2]));
275+
}
276+
}
277+
176278
for (const k of kids) walk(k);
177279

178280
if (isScope(kind)) scopeStack.pop();
179281
}
180282

181283
walk(ast.root);
182284

285+
function getCallArgs(callId: number): CallArg[] {
286+
const call = nodeAt(callId);
287+
if (!call || call.kind !== 'FunctionCallExprSyntax') return [];
288+
const out: CallArg[] = [];
289+
// Search shallow descendants up to depth 2 for labeled exprs
290+
const level1 = kidsOf(callId);
291+
const level2 = level1.flatMap(k => kidsOf(k));
292+
const candidateIds = [...level1, ...level2];
293+
for (const id of candidateIds) {
294+
const n = nodeAt(id);
295+
if (!n) continue;
296+
if (n.kind === 'LabeledExprSyntax' || n.kind === 'TupleExprElementSyntax') {
297+
const txt = slice(source, n.range);
298+
const m = txt.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*/);
299+
let label: string | undefined;
300+
let valueStart = n.range.start.offset;
301+
if (m) { label = m[1]; valueStart += m[0].length; }
302+
const valueRange: Range = { start: { ...n.range.start, offset: valueStart }, end: n.range.end };
303+
const valueText = source.slice(valueStart, n.range.end.offset).trim();
304+
out.push({ label, text: valueText, range: valueRange, nodeId: id });
305+
}
306+
}
307+
return out;
308+
}
309+
310+
function parseValue(text: string): any {
311+
const t = text.trim();
312+
if (t.startsWith('"') && t.endsWith('"')) return unquote(t);
313+
if (t === 'true') return true;
314+
if (t === 'false') return false;
315+
if (t === 'nil' || t === 'null') return null;
316+
if (/^-?\d+(?:\.\d+)?$/.test(t)) return Number(t);
317+
if (t.startsWith("[") && t.endsWith("]")) {
318+
const inner = t.slice(1, -1);
319+
const parts = splitTopLevel(inner, ',');
320+
return parts.map(p => parseValue(p));
321+
}
322+
if (t.startsWith("[") && t.includes(":")) {
323+
// dictionary fallback when we can't rely on kind
324+
return parseDict(text);
325+
}
326+
return t; // fallback raw
327+
}
328+
329+
function parseDict(text: string): any {
330+
const body = text.trim().replace(/^\[/, '').replace(/\]$/, '');
331+
const parts = splitTopLevel(body, ',');
332+
const obj: any = {};
333+
for (const part of parts) {
334+
if (!part) continue;
335+
const m = part.match(/^\s*("(?:[^"\\]|\\.)*")\s*:\s*([\s\S]*)$/);
336+
if (!m) continue;
337+
const key = unquote(m[1]);
338+
const val = parseValue(m[2]);
339+
obj[key] = val;
340+
}
341+
return obj;
342+
}
343+
344+
function extractDictionary(nodeId: number): any {
345+
const n = nodeAt(nodeId);
346+
if (!n) return undefined;
347+
const text = slice(source, n.range);
348+
return parseDict(text);
349+
}
350+
351+
function findEnclosing(nodeId: number, kinds?: string[]): SymbolInfo | undefined {
352+
const set = new Set(kinds && kinds.length ? kinds : ['FunctionDeclSyntax','ClassDeclSyntax','StructDeclSyntax']);
353+
let cur = parentOf(nodeId);
354+
while (cur !== undefined) {
355+
const n = nodeAt(cur);
356+
if (n && set.has(n.kind)) {
357+
const ids = byName.get(n.name ?? '') ?? [];
358+
for (const id of ids) {
359+
const s = symbols.get(id);
360+
if (s && s.id === cur) return s;
361+
}
362+
// fallback if symbol map missed it
363+
const k = classifyDeclKind(n.kind);
364+
if (k && n.name) {
365+
return { id: cur, kind: k, name: n.name, range: n.range, parentId: parentOf(cur) };
366+
}
367+
}
368+
cur = parentOf(cur!);
369+
}
370+
return undefined;
371+
}
372+
373+
function getStringLiteralValue(nodeId: number): string | undefined {
374+
const n = nodeAt(nodeId);
375+
if (!n) return undefined;
376+
const t = slice(source, n.range).trim();
377+
if (t.startsWith('"') && t.endsWith('"')) return unquote(t);
378+
return undefined;
379+
}
380+
183381
return {
184382
symbols,
185383
calls,
186384
refs,
187385
byName,
386+
getNode: (id: number) => nodeAt(id),
387+
getChildren: (id: number) => kidsOf(id),
388+
getParent: (id: number) => parentOf(id),
389+
getCalleeText: (callId: number) => calleeTextForCall(callId),
390+
getStringLiteralValue,
391+
getCallArgs,
392+
extractDictionary,
393+
findEnclosing,
394+
collectConstStrings: () => constStrings,
188395
findCallsByName: (name: string) => calls.filter(c => c.name === name),
189396
resolveNameAt: (name: string, _offset: number) => {
190397
const ids = byName.get(name);

tests/analyze.test.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,7 @@ import { parseSwiftAst, analyzeAst } from '../dist/index.js';
66

77
const FIX_DIR = join(process.cwd(), 'tests', 'fixtures');
88
const FILE = join(FIX_DIR, 'analyze.swift');
9-
10-
const SOURCE = `
11-
struct Foo {
12-
let x: Int
13-
func bar(_ y: Int) -> Int { x + y }
14-
}
15-
16-
func bar(_ n: Int) -> Int { n * 2 }
17-
18-
let a = Foo()
19-
let b = bar(3)
20-
let c = a.bar(4)
21-
`;
9+
const SOURCE = await fs.readFile(FILE, 'utf8');
2210

2311
await fs.mkdir(FIX_DIR, { recursive: true });
2412
await fs.writeFile(FILE, SOURCE, 'utf8');
@@ -38,4 +26,21 @@ test('analyzeAst extracts symbols and calls', async () => {
3826
// At least one receiver-less and one with receiver
3927
assert.ok(callsBar.some(c => !c.receiver));
4028
assert.ok(callsBar.some(c => c.receiver === 'a'));
29+
30+
// Callee chain
31+
const callInst = callsBar.find(c => c.receiver === 'a');
32+
assert.ok(callInst?.calleeChain && callInst.calleeChain.at(-1) === 'bar');
33+
34+
// Args
35+
if (callInst) {
36+
const args = analysis.getCallArgs(callInst.id);
37+
assert.ok(Array.isArray(args));
38+
}
39+
40+
// Enclosing function for a global call should be undefined or global
41+
const globalCall = callsBar.find(c => !c.receiver);
42+
if (globalCall) {
43+
const enc = analysis.findEnclosing(globalCall.id);
44+
assert.ok(!enc || enc.kind === 'function');
45+
}
4146
});

tests/fixtures/analyze.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
struct Foo {
2+
let x: Int
3+
func bar(_ y: Int) -> Int { x + y }
4+
}
5+
6+
func bar(_ n: Int) -> Int { n * 2 }
7+
8+
let a = Foo()
9+
let b = bar(3)
10+
let c = a.bar(4)

tests/fixtures/simple.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
struct Foo {
32
let x: Int
43
func bar(_ y: Int) -> Int { x + y }

0 commit comments

Comments
 (0)