Skip to content

Commit 972f234

Browse files
committed
ast analyzer
1 parent 3122c91 commit 972f234

File tree

4 files changed

+358
-10
lines changed

4 files changed

+358
-10
lines changed

README.md

Lines changed: 122 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,45 +4,154 @@ Swift AST parsing in JavaScript via WebAssembly (WASM), powered by SwiftSyntax +
44

55
[![NPM version](https://img.shields.io/npm/v/@flisk/swift-ast.svg)](https://www.npmjs.com/package/@flisk/swift-ast) [![Tests](https://github.com/fliskdata/swift-ast/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/fliskdata/swift-ast/actions/workflows/tests.yml)
66

7-
## Quick Start
7+
## Introduction
88

9-
Run without installation! Just use:
9+
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:
10+
11+
- extract declarations (functions, methods, classes, structs, enums, variables)
12+
- find and inspect function/method calls
13+
- follow simple identifier references within lexical scope
14+
- surface naive type hints where present (e.g., `let x: Int`)
15+
16+
It’s designed for program analysis pipelines like `fliskdata/analyze-tracking` and can also be used as a CLI with `npx`.
17+
18+
## Quick Start (CLI)
19+
20+
Run without installing:
1021

1122
```bash
1223
npx @flisk/swift-ast /path/to/file.swift
1324
```
1425

26+
This prints the parsed AST JSON to stdout.
27+
1528
## Install
1629

1730
```bash
1831
npm i @flisk/swift-ast
1932
```
2033

21-
## Usage
34+
## Programmatic API
35+
36+
### Parse to JSON AST
2237

2338
```ts
2439
import { parseSwiftAst } from '@flisk/swift-ast';
2540

26-
const ast = await parseSwiftAst(`
41+
const source = `
2742
struct Foo {
2843
let x: Int
2944
func bar(_ y: Int) -> Int { x + y }
3045
}
31-
`);
46+
`;
3247

48+
const ast = await parseSwiftAst(source);
3349
console.dir(ast, { depth: null });
3450
```
3551

52+
AST shape (simplified):
53+
54+
```json
55+
{
56+
"root": 0,
57+
"nodes": [
58+
{ "kind": "SourceFileSyntax", "range": { "start": {"offset":0}, "end": {"offset":123} } },
59+
{ "kind": "StructDeclSyntax", "name": "Foo", "range": { /* ... */ } },
60+
{ "kind": "FunctionDeclSyntax", "name": "bar", "range": { /* ... */ } }
61+
],
62+
"edges": [[0,1],[1,2]]
63+
}
64+
```
65+
66+
There’s also a convenience for file-based parsing:
67+
68+
```ts
69+
import { parseSwiftFile } from '@flisk/swift-ast';
70+
const astFromFile = await parseSwiftFile('path/to/file.swift');
71+
```
72+
73+
### AST analyzer
74+
75+
```ts
76+
import { analyzeAst } from '@flisk/swift-ast';
77+
78+
const analysis = analyzeAst(ast, source);
79+
80+
// All declarations by name
81+
console.log([...analysis.byName.keys()]);
82+
83+
// All calls to a function/method named "track"
84+
const calls = analysis.findCallsByName('track');
85+
for (const c of calls) {
86+
console.log({ name: c.name, receiver: c.receiver, args: c.argsCount, at: c.range.start });
87+
}
88+
89+
// Resolve a simple name to its symbol (best-effort)
90+
const symbol = analysis.resolveNameAt('bar', /*offset*/ 0);
91+
console.log(symbol?.kind, symbol?.name);
92+
```
93+
94+
Analyzer return type (high-level):
95+
96+
```ts
97+
type Analysis = {
98+
symbols: Map<number, SymbolInfo>;
99+
calls: CallInfo[];
100+
refs: RefInfo[];
101+
byName: Map<string, number[]>;
102+
findCallsByName(name: string): CallInfo[];
103+
resolveNameAt(name: string, offset: number): SymbolInfo | undefined;
104+
}
105+
```
106+
107+
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.
110+
111+
## CLI usage
112+
113+
```bash
114+
# Print AST JSON
115+
npx @flisk/swift-ast /path/to/file.swift
116+
117+
# With a local install
118+
swift-ast /path/to/file.swift > ast.json
119+
```
120+
121+
## Recipes
122+
123+
- **List all class/struct names**
124+
125+
```ts
126+
const decls = [...analysis.symbols.values()].filter(s => s.kind === 'class' || s.kind === 'struct');
127+
console.log(decls.map(d => d.name));
128+
```
129+
130+
- **Find all callsites of a specific API (e.g., analytics)**
131+
132+
```ts
133+
const hits = analysis.findCallsByName('track');
134+
for (const call of hits) {
135+
// receiver could be an instance, a type, or omitted
136+
console.log(`${call.receiver ? call.receiver + '.' : ''}${call.name} at ${call.range.start.line}:${call.range.start.column}`);
137+
}
138+
```
139+
140+
- **Get naive type info for variables**
141+
142+
```ts
143+
const vars = [...analysis.symbols.values()].filter(s => s.kind === 'variable');
144+
for (const v of vars) {
145+
console.log(v.name, '::', v.typeAnnotation ?? '(inferred)');
146+
}
147+
```
148+
36149
## Environment
37150

38151
- Node >= 18 (uses built-in `node:wasi`).
39152
- No native Swift toolchain required at runtime; the Wasm binary ships with the npm package.
40153

41-
## Notes
42-
43-
- SwiftSyntax version is tied to the Swift toolchain used to build the Wasm. If you rebuild locally, ensure a matching `swift-syntax` tag for your toolchain.
44-
45-
## Development
154+
## Development (for contributors)
46155

47156
1. Install Swift via [swiftly](https://www.swift.org/install)
48157
2. Install Swift 6.2: `swiftly install 6.2`
@@ -53,3 +162,6 @@ swift sdk install https://download.swift.org/swift-6.2-release/wasm/swift-6.2-RE
53162
```
54163
5. Run `swift sdk list` and ensure that `SWIFT_SDK_ID` is set to the appropriate SDK.
55164
6. Build the WASM binary: `npm run build`
165+
7. Run tests: `npm test`
166+
167+
Note: SwiftSyntax version is tied to the Swift toolchain used to build the WASM. If you rebuild locally, ensure a matching `swift-syntax` tag for your toolchain.

src/analyze.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
type Range = {
2+
start: { line: number, column: number, offset: number },
3+
end: { line: number, column: number, offset: number }
4+
};
5+
6+
type AstNode = {
7+
kind: string;
8+
range: Range;
9+
name?: string;
10+
tokenText?: string;
11+
};
12+
13+
type Ast = {
14+
root: number;
15+
nodes: AstNode[];
16+
edges: [number, number][];
17+
};
18+
19+
export type SymbolInfo = {
20+
id: number;
21+
kind: 'function'|'method'|'class'|'struct'|'enum'|'variable';
22+
name: string;
23+
range: Range;
24+
parentId?: number;
25+
typeAnnotation?: string;
26+
};
27+
28+
export type CallInfo = {
29+
id: number;
30+
name: string;
31+
receiver?: string;
32+
argsCount: number;
33+
range: Range;
34+
refersToId?: number;
35+
};
36+
37+
export type RefInfo = {
38+
id: number;
39+
name: string;
40+
range: Range;
41+
refersToId?: number;
42+
};
43+
44+
export type Analysis = {
45+
symbols: Map<number, SymbolInfo>;
46+
calls: CallInfo[];
47+
refs: RefInfo[];
48+
byName: Map<string, number[]>;
49+
findCallsByName: (name: string) => CallInfo[];
50+
resolveNameAt: (name: string, offset: number) => SymbolInfo | undefined;
51+
};
52+
53+
function slice(source: string, r: Range): string {
54+
return source.slice(r.start.offset, r.end.offset);
55+
}
56+
57+
function isScope(kind: string): boolean {
58+
return kind === 'FunctionDeclSyntax' || kind === 'ClassDeclSyntax' || kind === 'StructDeclSyntax' || kind === 'EnumDeclSyntax';
59+
}
60+
61+
function classifyDeclKind(kind: string): SymbolInfo['kind'] | undefined {
62+
if (kind === 'FunctionDeclSyntax') return 'function';
63+
if (kind === 'ClassDeclSyntax') return 'class';
64+
if (kind === 'StructDeclSyntax') return 'struct';
65+
if (kind === 'EnumDeclSyntax') return 'enum';
66+
if (kind === 'VariableDeclSyntax') return 'variable';
67+
return undefined;
68+
}
69+
70+
function extractTypeAnnotation(text: string): string | undefined {
71+
// naive: capture text after ':' up to '=' or end of declaration
72+
const m = text.match(/:\s*([^=\n\r\{]+?)(?=\s*(=|$|\n|\r|\{))/);
73+
return m ? m[1].trim() : undefined;
74+
}
75+
76+
function extractCallFromText(text: string): { name: string, receiver?: string, argsCount: number } | undefined {
77+
// naive: match receiver.optional + dotted path or identifier then '('
78+
const m = text.match(/([A-Za-z_][A-Za-z0-9_\.]*?)\s*\(/);
79+
if (!m) return undefined;
80+
const full = m[1];
81+
const parts = full.split('.');
82+
const name = parts.pop() || full;
83+
const receiver = parts.length ? parts.join('.') : undefined;
84+
// count arguments by commas at top level inside the first (...) pair
85+
const open = text.indexOf('(');
86+
if (open === -1) return { name, receiver, argsCount: 0 };
87+
let depth = 0, i = open, end = -1;
88+
for (; i < text.length; i++) {
89+
const ch = text[i];
90+
if (ch === '(') depth++;
91+
else if (ch === ')') { depth--; if (depth === 0) { end = i; break; } }
92+
}
93+
const inside = end !== -1 ? text.slice(open + 1, end) : '';
94+
const argsCount = inside.trim() === '' ? 0 : inside.split(',').length;
95+
return { name, receiver, argsCount };
96+
}
97+
98+
export function analyzeAst(ast: Ast, source: string): Analysis {
99+
const children = new Map<number, number[]>();
100+
const parent = new Map<number, number>();
101+
for (const [p, c] of ast.edges) {
102+
const arr = children.get(p) ?? [];
103+
arr.push(c);
104+
children.set(p, arr);
105+
parent.set(c, p);
106+
}
107+
108+
const symbols = new Map<number, SymbolInfo>();
109+
const byName = new Map<string, number[]>();
110+
const calls: CallInfo[] = [];
111+
const refs: RefInfo[] = [];
112+
113+
// DFS with scope stack of maps: name -> symbolId
114+
const scopeStack: Array<Map<string, number>> = [new Map()];
115+
116+
function addSymbol(id: number, info: SymbolInfo) {
117+
symbols.set(id, info);
118+
const ids = byName.get(info.name) ?? [];
119+
ids.push(id);
120+
byName.set(info.name, ids);
121+
const top = scopeStack[scopeStack.length - 1];
122+
top.set(info.name, id);
123+
}
124+
125+
function resolveName(name: string): number | undefined {
126+
for (let i = scopeStack.length - 1; i >= 0; i--) {
127+
const id = scopeStack[i].get(name);
128+
if (id !== undefined) return id;
129+
}
130+
const global = byName.get(name);
131+
return global && global.length ? global[0] : undefined;
132+
}
133+
134+
function walk(id: number) {
135+
const node = ast.nodes[id];
136+
const kind = node.kind;
137+
const kids = children.get(id) ?? [];
138+
139+
// Declarations
140+
const declKind = classifyDeclKind(kind);
141+
if (declKind) {
142+
const info: SymbolInfo = {
143+
id,
144+
kind: declKind,
145+
name: node.name ?? '',
146+
range: node.range,
147+
parentId: parent.get(id)
148+
};
149+
if (declKind === 'variable') {
150+
const text = slice(source, node.range);
151+
info.typeAnnotation = extractTypeAnnotation(text);
152+
}
153+
addSymbol(id, info);
154+
}
155+
156+
// Push scope for scope-introducing nodes
157+
if (isScope(kind)) scopeStack.push(new Map());
158+
159+
// Calls and references
160+
if (kind === 'FunctionCallExprSyntax') {
161+
const text = slice(source, node.range);
162+
const c = extractCallFromText(text);
163+
if (c) {
164+
const refersToId = resolveName(c.name);
165+
calls.push({ id, name: c.name, receiver: c.receiver, argsCount: c.argsCount, range: node.range, refersToId });
166+
}
167+
}
168+
if (kind === 'DeclReferenceExprSyntax') {
169+
const text = slice(source, node.range);
170+
const m = text.match(/[A-Za-z_][A-Za-z0-9_]*/);
171+
const name = m ? m[0] : (node.name ?? '');
172+
const refersToId = name ? resolveName(name) : undefined;
173+
refs.push({ id, name, range: node.range, refersToId });
174+
}
175+
176+
for (const k of kids) walk(k);
177+
178+
if (isScope(kind)) scopeStack.pop();
179+
}
180+
181+
walk(ast.root);
182+
183+
return {
184+
symbols,
185+
calls,
186+
refs,
187+
byName,
188+
findCallsByName: (name: string) => calls.filter(c => c.name === name),
189+
resolveNameAt: (name: string, _offset: number) => {
190+
const ids = byName.get(name);
191+
return ids && ids[0] !== undefined ? symbols.get(ids[0]) : undefined;
192+
}
193+
};
194+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { promises as fs } from 'node:fs';
22
import { join } from 'node:path';
33
import { getInstance, u8 } from './wasi-loader.js';
4+
export * from './analyze.js';
45

56
type WasmExports = {
67
memory: WebAssembly.Memory;

0 commit comments

Comments
 (0)