Skip to content

Commit 40b6efd

Browse files
Merge pull request giancarloerra#73 from giancarloerra/feat/dart-symbol-graph
feat(graph): full Dart support via tree-sitter AST
2 parents a76fa7e + 14a2003 commit 40b6efd

10 files changed

Lines changed: 438 additions & 14 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -959,13 +959,15 @@ SocratiCode supports languages at three levels:
959959
960960
### Full Support (indexing + code graph + AST chunking)
961961
962-
JavaScript, TypeScript, TSX, Python, Java, Kotlin, Scala, C, C++, C#, Go, Rust, Ruby, PHP, Swift, Bash/Shell, HTML, CSS/SCSS, Svelte, Vue
962+
JavaScript, TypeScript, TSX, Python, Java, Kotlin, Scala, C, C++, C#, Go, Rust, Ruby, PHP, Swift, Dart, Bash/Shell, HTML, CSS/SCSS, Svelte, Vue
963963
964964
Svelte and Vue: imports extracted from `<script>` blocks (re-parsed as TypeScript) and CSS `@import`/`@require` from `<style>` blocks (any combination of `lang`, `scoped`, `module`, `global` attributes). Path aliases from `tsconfig.json`/`jsconfig.json` `compilerOptions.paths` are resolved (including `extends` chains). SCSS partial resolution (`_` prefix convention) is supported.
965965
966+
Dart: symbols (classes, mixins, enums, extensions, typedefs, functions, getters/setters, constructors including named and factory), call sites (method calls, cascades, constructor invocations), `main()` entry-point detection, and AST chunking are all tree-sitter based; import/export/part edges are extracted via regex.
967+
966968
### Code Graph via Regex + Indexing
967969
968-
Dart (import/export/part), Lua (require/dofile/loadfile), SASS, LESS (CSS `@import` extraction)
970+
Lua (require/dofile/loadfile), SASS, LESS (CSS `@import` extraction)
969971
970972
### Indexing Only (hybrid search, line-based chunking)
971973

package-lock.json

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"@ast-grep/lang-c": "^0.0.5",
6868
"@ast-grep/lang-cpp": "^0.0.5",
6969
"@ast-grep/lang-csharp": "^0.0.5",
70+
"@ast-grep/lang-dart": "^0.0.7",
7071
"@ast-grep/lang-go": "^0.0.5",
7172
"@ast-grep/lang-java": "^0.0.6",
7273
"@ast-grep/lang-kotlin": "^0.0.6",

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export const ENTRY_POINT_NAMES: Record<string, Set<string>> = {
163163
swift: new Set(["main"]),
164164
ruby: new Set(["main"]),
165165
php: new Set(["main"]),
166+
dart: new Set(["main"]),
166167
};
167168

168169
// ── Path normalization ──────────────────────────────────────────────────

src/services/code-graph.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,7 @@ export function ensureDynamicLanguages(): void {
493493
["bash", "@ast-grep/lang-bash"],
494494
["php", "@ast-grep/lang-php"],
495495
["lua", "@ast-grep/lang-lua"],
496+
["dart", "@ast-grep/lang-dart"],
496497
];
497498

498499
for (const [name, pkg] of langPackages) {

src/services/graph-symbols.ts

Lines changed: 214 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,10 @@ export function extractSymbolsAndCalls(
145145
if (langKey === "lua") {
146146
return extractFromLua(source, relativePath, language, moduleSymbol);
147147
}
148-
// Dart, Svelte, Vue and others fall through to the regex fallback.
148+
if (langKey === "dart") {
149+
return extractFromDart(source, relativePath, language, moduleSymbol);
150+
}
151+
// Svelte, Vue and others fall through to the regex fallback.
149152
return extractFromRegex(source, relativePath, language, moduleSymbol);
150153
} catch (err) {
151154
if (!symbolExtractionWarned.has(langKey)) {
@@ -276,6 +279,216 @@ function extractFromLua(
276279
return { symbols, rawCalls };
277280
}
278281

282+
// ── Dart (type-first signatures, sibling signature/body pairs, selector calls) ──
283+
284+
/**
285+
* Dart previously fell through to the regex fallback, which cannot match
286+
* type-first signatures (`void foo()`, `Future<int> baz() async`), so
287+
* classes, methods, and call sites were invisible to the symbol graph.
288+
* This walks the ast-grep Dart tree instead. Grammar quirks handled here:
289+
* class/mixin/enum/extension nodes span their bodies, but a function is a
290+
* `function_signature` followed by a SIBLING `function_body`, so scope
291+
* ranges are stitched from each pair; plain constructors live inside a
292+
* generic `declaration` wrapper; and there is no call_expression kind, so
293+
* calls are recovered from `argument_part` nodes (callee = the preceding
294+
* identifier or selector chain, or the `cascade_selector` for `..` calls).
295+
*/
296+
function extractFromDart(
297+
source: string,
298+
file: string,
299+
language: string,
300+
moduleSym: SymbolNode,
301+
): ExtractedSymbols {
302+
const root = parse("dart" as unknown as Lang, source).root();
303+
const symbols: SymbolNode[] = [moduleSym];
304+
const scopes: ScopeFrame[] = [];
305+
306+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
307+
const kidsOf = (n: any): any[] => {
308+
try {
309+
return n.children();
310+
} catch {
311+
return [];
312+
}
313+
};
314+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
315+
const childOfKind = (n: any, kind: string): any | null =>
316+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
317+
kidsOf(n).find((c: any) => c.kind() === kind) ?? null;
318+
// Direct identifier children only — the name slot. Type annotations are
319+
// `type_identifier`/`void_type` and parameter names are nested deeper, so
320+
// they never appear here.
321+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
322+
const idChildren = (n: any): any[] =>
323+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
324+
kidsOf(n).filter((c: any) => c.kind() === "identifier");
325+
326+
const addSym = (
327+
name: string,
328+
qualifiedName: string,
329+
kind: SymbolKind,
330+
startLine: number,
331+
endLine: number,
332+
): void => {
333+
const sym: SymbolNode = {
334+
id: makeId(file, qualifiedName, startLine),
335+
name,
336+
qualifiedName,
337+
kind,
338+
file,
339+
line: startLine,
340+
endLine,
341+
language,
342+
};
343+
symbols.push(sym);
344+
scopes.push({ name: qualifiedName, startLine, endLine, symbolId: sym.id });
345+
};
346+
347+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
348+
const lineOf = (n: any): number => n.range().start.line + 1;
349+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
350+
const endLineOf = (n: any): number => n.range().end.line + 1;
351+
352+
/**
353+
* Emit the member symbols of a class-like body. Members come in ordered
354+
* sibling pairs: a `method_signature` (wrapping function/getter/setter/
355+
* factory signatures) or a `declaration` (fields and plain constructors),
356+
* optionally followed by its `function_body`.
357+
*/
358+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
359+
const walkMembers = (bodyNode: any, owner: string): void => {
360+
const members = kidsOf(bodyNode);
361+
for (let i = 0; i < members.length; i++) {
362+
const member = members[i];
363+
const memberKind = member.kind();
364+
const next = members[i + 1];
365+
const scopeEnd = next && next.kind() === "function_body" ? endLineOf(next) : endLineOf(member);
366+
367+
if (memberKind === "method_signature") {
368+
const inner = kidsOf(member)[0];
369+
if (!inner) continue;
370+
const innerKind = inner.kind();
371+
if (innerKind === "factory_constructor_signature") {
372+
const ids = idChildren(inner);
373+
if (ids.length === 0) continue;
374+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
375+
const qn = ids.map((c: any) => c.text()).join(".");
376+
addSym(ids[ids.length - 1].text(), qn, "constructor", lineOf(member), scopeEnd);
377+
} else if (
378+
innerKind === "function_signature" ||
379+
innerKind === "getter_signature" ||
380+
innerKind === "setter_signature"
381+
) {
382+
const ids = idChildren(inner);
383+
if (ids.length === 0) continue;
384+
const name = ids[ids.length - 1].text();
385+
addSym(name, `${owner}.${name}`, "method", lineOf(member), scopeEnd);
386+
}
387+
} else if (memberKind === "declaration") {
388+
// Plain (possibly named) constructors: `Foo(this.c);` / `Foo.named(...)`.
389+
// Field declarations have no constructor_signature child and are skipped.
390+
const ctor = childOfKind(member, "constructor_signature");
391+
if (!ctor) continue;
392+
const ids = idChildren(ctor);
393+
if (ids.length === 0) continue;
394+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
395+
const qn = ids.map((c: any) => c.text()).join(".");
396+
addSym(ids[ids.length - 1].text(), qn, "constructor", lineOf(member), scopeEnd);
397+
}
398+
}
399+
};
400+
401+
// ── Top-level declarations (ordered walk so signature/body pairs line up) ──
402+
// Dart 3.3 `extension type` is NOT handled: the vendored grammar
403+
// (@ast-grep/lang-dart 0.0.7) predates the syntax and parses it to ERROR
404+
// nodes (no extension_type_declaration kind exists), so such declarations
405+
// degrade to "not extracted" while the rest of the file extracts normally.
406+
// Revisit when the upstream grammar adds the kind.
407+
const topLevel = kidsOf(root);
408+
for (let i = 0; i < topLevel.length; i++) {
409+
const node = topLevel[i];
410+
const nodeKind = node.kind();
411+
412+
if (nodeKind === "class_definition" || nodeKind === "mixin_declaration" || nodeKind === "extension_declaration") {
413+
const nameNode = childOfKind(node, "identifier");
414+
if (!nameNode) continue;
415+
const name = nameNode.text();
416+
const kind: SymbolKind = nodeKind === "mixin_declaration" ? "trait" : "class";
417+
addSym(name, name, kind, lineOf(node), endLineOf(node));
418+
const body = childOfKind(node, "class_body") ?? childOfKind(node, "extension_body");
419+
if (body) walkMembers(body, name);
420+
} else if (nodeKind === "enum_declaration") {
421+
const nameNode = childOfKind(node, "identifier");
422+
if (nameNode) addSym(nameNode.text(), nameNode.text(), "enum", lineOf(node), endLineOf(node));
423+
} else if (nodeKind === "type_alias") {
424+
const nameNode = childOfKind(node, "type_identifier");
425+
if (nameNode) addSym(nameNode.text(), nameNode.text(), "interface", lineOf(node), endLineOf(node));
426+
} else if (nodeKind === "function_signature" || nodeKind === "getter_signature" || nodeKind === "setter_signature") {
427+
const ids = idChildren(node);
428+
if (ids.length === 0) continue;
429+
const name = ids[ids.length - 1].text();
430+
const next = topLevel[i + 1];
431+
const scopeEnd = next && next.kind() === "function_body" ? endLineOf(next) : endLineOf(node);
432+
addSym(name, name, "function", lineOf(node), scopeEnd);
433+
}
434+
}
435+
436+
// ── Calls — every invocation wraps an `argument_part` node ──────────────
437+
const rawCalls: ExtractedSymbols["rawCalls"] = [];
438+
for (const ap of safeFindAll(root, "argument_part")) {
439+
const holder = ap.parent();
440+
if (!holder) continue;
441+
const holderKind = holder.kind();
442+
let callee: string | null = null;
443+
444+
if (holderKind === "cascade_section") {
445+
// `obj..method(args)` — the callee lives in the cascade_selector.
446+
const cs = childOfKind(holder, "cascade_selector");
447+
const id = cs ? childOfKind(cs, "identifier") : null;
448+
callee = id ? id.text() : null;
449+
} else if (holderKind === "selector") {
450+
// `name(args)` / `expr.name(args)` — the callee is the previous
451+
// sibling: a bare identifier, or a selector whose trailing identifier
452+
// is the method name (`f.bar(…)`, `mat.runApp(…)`, `Foo.create(…)`).
453+
const parent = holder.parent();
454+
if (!parent) continue;
455+
const siblings = kidsOf(parent);
456+
const hr = holder.range();
457+
const idx = siblings.findIndex(
458+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
459+
(c: any) => {
460+
if (c.kind() !== "selector") return false;
461+
const r = c.range();
462+
return (
463+
r.start.line === hr.start.line &&
464+
r.start.column === hr.start.column &&
465+
r.end.line === hr.end.line &&
466+
r.end.column === hr.end.column
467+
);
468+
},
469+
);
470+
if (idx <= 0) continue;
471+
const prev = siblings[idx - 1];
472+
if (prev.kind() === "identifier") {
473+
callee = prev.text();
474+
} else if (prev.kind() === "selector") {
475+
const ids = safeFindAll(prev, "identifier");
476+
callee = ids.length > 0 ? ids[ids.length - 1].text() : null;
477+
}
478+
}
479+
480+
if (!callee) continue;
481+
const line = ap.range().start.line + 1;
482+
rawCalls.push({
483+
callerId: findCallerId(scopes, line, moduleSym.id),
484+
calleeName: callee,
485+
callSite: { file, line },
486+
});
487+
}
488+
489+
return { symbols, rawCalls };
490+
}
491+
279492
// ── JS / TS / TSX ────────────────────────────────────────────────────────
280493

281494
function extractFromTsLike(

src/services/indexer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,11 @@ const TOP_LEVEL_KINDS: Record<string, string[]> = {
272272
php: ["function_definition", "class_declaration", "method_declaration", "trait_declaration"],
273273
swift: ["function_declaration", "class_declaration", "struct_declaration", "protocol_declaration", "extension_declaration"],
274274
bash: ["function_definition"],
275+
// Dart: class/mixin/enum/extension nodes span their bodies, but a top-level
276+
// function is a `function_signature` followed by a SIBLING `function_body`
277+
// starting on the same line. Both kinds are listed so the overlap-merge in
278+
// findAstBoundaries fuses each signature/body pair into one region.
279+
dart: ["class_definition", "mixin_declaration", "enum_declaration", "extension_declaration", "type_alias", "function_signature", "function_body"],
275280
};
276281

277282
/** Minimum lines for a chunk to stand on its own (otherwise merge with neighbors) */

tests/unit/graph-entrypoints.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,28 @@ describe("graph-entrypoints", () => {
9696
expect(entries.some((e) => e.name === "main")).toBe(true);
9797
});
9898

99+
it("detects Dart main() as a conventional entry point", () => {
100+
const graph: CodeGraph = {
101+
nodes: [
102+
{
103+
relativePath: "lib/main.dart",
104+
imports: [],
105+
exports: [],
106+
dependencies: [],
107+
dependents: ["lib/app.dart"], // not an orphan — name heuristic must fire
108+
},
109+
],
110+
edges: [],
111+
};
112+
const payloads: SymbolGraphFilePayload[] = [
113+
{ ...mkPayload("lib/main.dart", [{ name: "main", line: 3 }]), language: "dart" },
114+
];
115+
const entries = detectEntryPoints(graph, payloads);
116+
const main = entries.find((e) => e.name === "main");
117+
expect(main).toBeDefined();
118+
expect(main?.reason).toBe("well-known-name:main");
119+
});
120+
99121
it("returns empty array when nothing matches", () => {
100122
const graph: CodeGraph = { nodes: [], edges: [] };
101123
const entries = detectEntryPoints(graph, []);

0 commit comments

Comments
 (0)