From 5f717949be8f6ea998364bfa70e3cf3cb3582a72 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Sat, 18 Jul 2026 19:42:55 +0300 Subject: [PATCH 1/3] feat(tree-sitter): add Dart definition support --- .../__tests__/fixtures/sample-dart.ts | 58 +++++++++++++++++ .../__tests__/languageParser.spec.ts | 6 ++ .../parseSourceCodeDefinitions.dart.spec.ts | 36 ++++++++++ src/services/tree-sitter/index.ts | 2 + src/services/tree-sitter/languageParser.ts | 5 ++ src/services/tree-sitter/queries/dart.ts | 65 +++++++++++++++++++ src/services/tree-sitter/queries/index.ts | 1 + 7 files changed, 173 insertions(+) create mode 100644 src/services/tree-sitter/__tests__/fixtures/sample-dart.ts create mode 100644 src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts create mode 100644 src/services/tree-sitter/queries/dart.ts diff --git a/src/services/tree-sitter/__tests__/fixtures/sample-dart.ts b/src/services/tree-sitter/__tests__/fixtures/sample-dart.ts new file mode 100644 index 0000000000..e5ad987da7 --- /dev/null +++ b/src/services/tree-sitter/__tests__/fixtures/sample-dart.ts @@ -0,0 +1,58 @@ +export default `abstract class Animal { + String speak(); +} + +class Point { + const Point(); + Point.named(); + factory Point.origin() => const Point(); + + int get x => 0; + set x(int value) {} + + Point operator +(Point other) => this; +} + +mixin Runner { + void run() { + print("running"); + } +} + +enum Status { + ready, + running; + + const Status(); +} + +class Dog extends Animal with Runner { + final String name; + + Dog(this.name); + + @override + String speak() { + return "\$name barks"; + } +} + +extension StringTools on String { + String doubled() { + return this + this; + } +} + +extension type UserId(int value) { + UserId.zero() : value = 0; +} + +typedef Operation = int Function(int left, int right); + +int get answer => 42; +set answer(int value) {} + +int add(int left, int right) { + return left + right; +} +` diff --git a/src/services/tree-sitter/__tests__/languageParser.spec.ts b/src/services/tree-sitter/__tests__/languageParser.spec.ts index 9bf9ecc881..fe4dcdb2d9 100644 --- a/src/services/tree-sitter/__tests__/languageParser.spec.ts +++ b/src/services/tree-sitter/__tests__/languageParser.spec.ts @@ -49,6 +49,12 @@ describe("loadRequiredLanguageParsers", () => { expect(parsers.kts.query).toBeDefined() }) + it("should load Dart parser for .dart files", async () => { + const parsers = await loadRequiredLanguageParsers(["test.dart"], WASM_DIR) + expect(parsers.dart).toBeDefined() + expect(parsers.dart.query).toBeDefined() + }) + it("should throw error for unsupported file extensions", async () => { const files = ["test.unsupported"] await expect(loadRequiredLanguageParsers(files, WASM_DIR)).rejects.toThrow("Unsupported language: unsupported") diff --git a/src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts b/src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts new file mode 100644 index 0000000000..31d84dcd07 --- /dev/null +++ b/src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts @@ -0,0 +1,36 @@ +import { dartQuery } from "../queries" +import { testParseSourceCodeDefinitions } from "./helpers" +import sampleDartContent from "./fixtures/sample-dart" + +const dartOptions = { + language: "dart", + wasmFile: "tree-sitter-dart.wasm", + queryString: dartQuery, + extKey: "dart", +} + +describe("parseSourceCodeDefinitionsForFile with Dart", () => { + it("should capture common Dart declarations", async () => { + const result = await testParseSourceCodeDefinitions("/test/file.dart", sampleDartContent, dartOptions) + + expect(result).toMatch(/abstract class Animal/) + expect(result).toMatch(/class Point/) + expect(result).toMatch(/const Point\(\)/) + expect(result).toMatch(/Point\.named\(\)/) + expect(result).toMatch(/factory Point\.origin\(\)/) + expect(result).toMatch(/int get x/) + expect(result).toMatch(/set x\(int value\)/) + expect(result).toMatch(/Point operator \+/) + expect(result).toMatch(/mixin Runner/) + expect(result).toMatch(/enum Status/) + expect(result).toMatch(/const Status\(\)/) + expect(result).toMatch(/class Dog extends Animal with Runner/) + expect(result).toMatch(/extension StringTools on String/) + expect(result).toMatch(/extension type UserId/) + expect(result).toMatch(/typedef Operation/) + expect(result).toMatch(/int get answer/) + expect(result).toMatch(/set answer\(int value\)/) + expect(result).toMatch(/String speak\(\)/) + expect(result).toMatch(/int add\(int left, int right\)/) + }) +}) diff --git a/src/services/tree-sitter/index.ts b/src/services/tree-sitter/index.ts index da20a07de7..f7be2c9d72 100644 --- a/src/services/tree-sitter/index.ts +++ b/src/services/tree-sitter/index.ts @@ -93,6 +93,8 @@ const extensions = [ "erb", // Visual Basic .NET "vb", + // Dart + "dart", ].map((e) => `.${e}`) export { extensions } diff --git a/src/services/tree-sitter/languageParser.ts b/src/services/tree-sitter/languageParser.ts index a8ac0a9ead..b7faf2cdba 100644 --- a/src/services/tree-sitter/languageParser.ts +++ b/src/services/tree-sitter/languageParser.ts @@ -28,6 +28,7 @@ import { embeddedTemplateQuery, elispQuery, elixirQuery, + dartQuery, } from "./queries" export interface LanguageParser { @@ -218,6 +219,10 @@ export async function loadRequiredLanguageParsers(filesToParse: string[], source language = await loadLanguage("elixir", sourceDirectory) query = new Query(language, elixirQuery) break + case "dart": + language = await loadLanguage("dart", sourceDirectory) + query = new Query(language, dartQuery) + break default: throw new Error(`Unsupported language: ${ext}`) } diff --git a/src/services/tree-sitter/queries/dart.ts b/src/services/tree-sitter/queries/dart.ts new file mode 100644 index 0000000000..ea59e7dc40 --- /dev/null +++ b/src/services/tree-sitter/queries/dart.ts @@ -0,0 +1,65 @@ +// Definition captures adapted from tree-sitter-dart's canonical tags query: +// https://github.com/UserNobody14/tree-sitter-dart/blob/master/queries/tags.scm +export default ` +(class_definition + name: (identifier) @name) @definition.class + +(method_signature + (function_signature)) @definition.method + +(type_alias + (type_identifier) @name) @definition.type + +(method_signature + (getter_signature + name: (identifier) @name)) @definition.method + +(method_signature + (setter_signature + name: (identifier) @name)) @definition.method + +(method_signature + (function_signature + name: (identifier) @name)) @definition.method + +(method_signature + (factory_constructor_signature + (identifier) @name)) @definition.method + +(method_signature + (constructor_signature + name: (identifier) @name)) @definition.method + +(method_signature + (operator_signature)) @definition.method + +(constructor_signature + name: (identifier) @name) @definition.method + +(constant_constructor_signature + (identifier) @name) @definition.method + +(mixin_declaration + (mixin) + (identifier) @name) @definition.mixin + +(extension_declaration + name: (identifier) @name) @definition.extension + +(extension_type_declaration + name: (identifier) @name) @definition.extension + +(enum_declaration + name: (identifier) @name) @definition.enum + +(program + (getter_signature + name: (identifier) @name) @definition.function) + +(program + (setter_signature + name: (identifier) @name) @definition.function) + +(function_signature + name: (identifier) @name) @definition.function +` diff --git a/src/services/tree-sitter/queries/index.ts b/src/services/tree-sitter/queries/index.ts index de9b9cafa3..0faa256269 100644 --- a/src/services/tree-sitter/queries/index.ts +++ b/src/services/tree-sitter/queries/index.ts @@ -26,3 +26,4 @@ export { zigQuery } from "./zig" export { default as embeddedTemplateQuery } from "./embedded_template" export { elispQuery } from "./elisp" export { scalaQuery } from "./scala" +export { default as dartQuery } from "./dart" From 8b19e9b5f52621bb0b5cd35b533e738b29a797be Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Wed, 22 Jul 2026 13:29:02 +0300 Subject: [PATCH 2/3] fix(tree-sitter): address Dart query review feedback --- .../__tests__/fixtures/sample-dart.ts | 55 +++++++++ .../parseSourceCodeDefinitions.dart.spec.ts | 113 +++++++++++++++--- src/services/tree-sitter/index.ts | 6 +- src/services/tree-sitter/queries/dart.ts | 32 ++--- 4 files changed, 163 insertions(+), 43 deletions(-) diff --git a/src/services/tree-sitter/__tests__/fixtures/sample-dart.ts b/src/services/tree-sitter/__tests__/fixtures/sample-dart.ts index e5ad987da7..d09f12ac35 100644 --- a/src/services/tree-sitter/__tests__/fixtures/sample-dart.ts +++ b/src/services/tree-sitter/__tests__/fixtures/sample-dart.ts @@ -1,5 +1,9 @@ export default `abstract class Animal { String speak(); + + Future describe({ + required bool verbose, + }); } class Point { @@ -7,10 +11,29 @@ class Point { Point.named(); factory Point.origin() => const Point(); + Point.fromCoordinates( + int x, + int y, + ) : assert(x >= 0), + assert(y >= 0); + + factory Point.fromRecord( + ({int x, int y}) coordinates, + ) => Point.fromCoordinates( + coordinates.x, + coordinates.y, + ); + int get x => 0; set x(int value) {} Point operator +(Point other) => this; + + Point operator [](int index) => this; + + static List emptyList() { + return []; + } } mixin Runner { @@ -43,16 +66,48 @@ extension StringTools on String { } } +extension on int { + int squared() => this * this; +} + extension type UserId(int value) { UserId.zero() : value = 0; } typedef Operation = int Function(int left, int right); +typedef AsyncOperation = Future Function( + T value, { + required Duration timeout, +}); + int get answer => 42; set answer(int value) {} int add(int left, int right) { return left + right; } + +Future retry( + Future Function() operation, { + int attempts = 3, +}) async { + return operation(); +} + +Future initialize() async { + await Future.value(); +} + +Iterable countUpTo(int maximum) sync* { + for (var value = 0; value <= maximum; value++) { + yield value; + } +} + +Stream countPeriodically(int maximum) async* { + for (var value = 0; value <= maximum; value++) { + yield value; + } +} ` diff --git a/src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts b/src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts index 31d84dcd07..89e5388dee 100644 --- a/src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts +++ b/src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts @@ -10,27 +10,102 @@ const dartOptions = { } describe("parseSourceCodeDefinitionsForFile with Dart", () => { + it("captures a redirecting factory constructor", async () => { + const content = `class Logger { + factory Logger() = ConsoleLogger; + } + + class ConsoleLogger implements Logger {}` + + const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions) + + expect(result).toMatch(/\d+--\d+ \|\s*factory Logger\(\) = ConsoleLogger/) + }) + + it("captures a multiline generative constructor once", async () => { + const content = `class Point { + Point.fromCoordinates( + int x, + int y, + ); + }` + + const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions) + + expect(result?.match(/\d+--\d+ \|\s*Point\.fromCoordinates\(/g)).toHaveLength(1) + }) + + it("captures a mixin application class", async () => { + const content = `class Base {} + mixin Serializable {} + class SerializableBase = Base with Serializable;` + + const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions) + + expect(result).toMatch(/\d+--\d+ \|\s*class SerializableBase = Base with Serializable/) + }) + + it("captures external top-level functions and methods", async () => { + const content = `external int nativeVersion(); + + class NativeApi { + external String platformName(); + }` + + const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions) + + expect(result).toMatch(/\d+--\d+ \| external int nativeVersion\(\)/) + expect(result).toMatch(/\d+--\d+ \|\s*external String platformName\(\)/) + }) + + it("does not report local functions as file-level definitions", async () => { + const content = `void outer() { + int inner() => 1; + print(inner()); + }` + + const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions) + + expect(result).toMatch(/\d+--\d+ \| void outer\(\)/) + expect(result).not.toMatch(/int inner\(\)/) + }) + it("should capture common Dart declarations", async () => { const result = await testParseSourceCodeDefinitions("/test/file.dart", sampleDartContent, dartOptions) + const definitionLines = result?.split("\n").filter((line) => line.includes(" | ")) ?? [] - expect(result).toMatch(/abstract class Animal/) - expect(result).toMatch(/class Point/) - expect(result).toMatch(/const Point\(\)/) - expect(result).toMatch(/Point\.named\(\)/) - expect(result).toMatch(/factory Point\.origin\(\)/) - expect(result).toMatch(/int get x/) - expect(result).toMatch(/set x\(int value\)/) - expect(result).toMatch(/Point operator \+/) - expect(result).toMatch(/mixin Runner/) - expect(result).toMatch(/enum Status/) - expect(result).toMatch(/const Status\(\)/) - expect(result).toMatch(/class Dog extends Animal with Runner/) - expect(result).toMatch(/extension StringTools on String/) - expect(result).toMatch(/extension type UserId/) - expect(result).toMatch(/typedef Operation/) - expect(result).toMatch(/int get answer/) - expect(result).toMatch(/set answer\(int value\)/) - expect(result).toMatch(/String speak\(\)/) - expect(result).toMatch(/int add\(int left, int right\)/) + expect(result).toMatch(/\d+--\d+ \| abstract class Animal/) + expect(result).toMatch(/\d+--\d+ \|\s*Future describe/) + expect(result).toMatch(/\d+--\d+ \| class Point/) + expect(result).toMatch(/\d+--\d+ \|\s*const Point\(\)/) + expect(result).toMatch(/\d+--\d+ \|\s*Point\.named\(\)/) + expect(result).toMatch(/\d+--\d+ \|\s*factory Point\.origin\(\)/) + expect(result).toMatch(/\d+--\d+ \|\s*Point\.fromCoordinates\(/) + expect(result).toMatch(/\d+--\d+ \|\s*factory Point\.fromRecord\(/) + expect(result?.match(/\d+--\d+ \| Point\.fromCoordinates\(/g)).toHaveLength(1) + expect(result?.match(/\d+--\d+ \| factory Point\.fromRecord\(/g)).toHaveLength(1) + expect(result).toMatch(/\d+--\d+ \|\s*int get x/) + expect(result).toMatch(/\d+--\d+ \|\s*set x\(int value\)/) + expect(result).toMatch(/\d+--\d+ \|\s*Point operator \+/) + expect(result).toMatch(/\d+--\d+ \|\s*Point operator \[]/) + expect(result).toMatch(/\d+--\d+ \|\s*static List emptyList/) + expect(result).toMatch(/\d+--\d+ \| mixin Runner/) + expect(result).toMatch(/\d+--\d+ \| enum Status/) + expect(result).toMatch(/\d+--\d+ \|\s*const Status\(\)/) + expect(result).toMatch(/\d+--\d+ \| class Dog extends Animal with Runner/) + expect(result).toMatch(/\d+--\d+ \| extension StringTools on String/) + expect(result).toMatch(/\d+--\d+ \| extension on int/) + expect(result).toMatch(/\d+--\d+ \| extension type UserId/) + expect(result).toMatch(/\d+--\d+ \| typedef Operation/) + expect(result).toMatch(/\d+--\d+ \| typedef AsyncOperation/) + expect(result).toMatch(/\d+--\d+ \| int get answer/) + expect(result).toMatch(/\d+--\d+ \| set answer\(int value\)/) + expect(result).toMatch(/\d+--\d+ \|\s*String speak\(\)/) + expect(result).toMatch(/\d+--\d+ \| int add\(int left, int right\)/) + expect(result).toMatch(/\d+--\d+ \| Future retry/) + expect(result).toMatch(/\d+--\d+ \| Future initialize\(\) async/) + expect(result).toMatch(/\d+--\d+ \| Iterable countUpTo\(int maximum\) sync\*/) + expect(result).toMatch(/\d+--\d+ \| Stream countPeriodically\(int maximum\) async\*/) + expect(definitionLines).toHaveLength(37) }) }) diff --git a/src/services/tree-sitter/index.ts b/src/services/tree-sitter/index.ts index f7be2c9d72..5dca6bc072 100644 --- a/src/services/tree-sitter/index.ts +++ b/src/services/tree-sitter/index.ts @@ -272,9 +272,11 @@ function processCaptures(captures: QueryCapture[], lines: string[], language: st if (node.parent && node.parent.lastChild) { const contextEnd = node.parent.lastChild.endPosition.row const contextSpan = contextEnd - node.parent.startPosition.row + 1 + const hasDistinctContextStart = node.parent.startPosition.row !== startLine - // Only include context if it spans multiple lines - if (contextSpan >= getMinComponentLines()) { + // Only include context when it adds a distinct source line. A parent + // starting on the definition line would echo the same declaration. + if (hasDistinctContextStart && contextSpan >= getMinComponentLines()) { // Add the full range first const rangeKey = `${node.parent.startPosition.row}-${contextEnd}` if (!processedLines.has(rangeKey)) { diff --git a/src/services/tree-sitter/queries/dart.ts b/src/services/tree-sitter/queries/dart.ts index ea59e7dc40..d4c67a05ac 100644 --- a/src/services/tree-sitter/queries/dart.ts +++ b/src/services/tree-sitter/queries/dart.ts @@ -4,34 +4,21 @@ export default ` (class_definition name: (identifier) @name) @definition.class -(method_signature - (function_signature)) @definition.method +(class_definition + (mixin_application_class + (identifier) @name)) @definition.class (type_alias (type_identifier) @name) @definition.type -(method_signature - (getter_signature - name: (identifier) @name)) @definition.method - -(method_signature - (setter_signature - name: (identifier) @name)) @definition.method - -(method_signature +(declaration (function_signature name: (identifier) @name)) @definition.method -(method_signature - (factory_constructor_signature - (identifier) @name)) @definition.method - -(method_signature - (constructor_signature - name: (identifier) @name)) @definition.method +(redirecting_factory_constructor_signature + (identifier) @name) @definition.method -(method_signature - (operator_signature)) @definition.method +(method_signature) @definition.method (constructor_signature name: (identifier) @name) @definition.method @@ -60,6 +47,7 @@ export default ` (setter_signature name: (identifier) @name) @definition.function) -(function_signature - name: (identifier) @name) @definition.function +(program + (function_signature + name: (identifier) @name) @definition.function) ` From 41007845c0d3c217d0a42018777f6eaaeb13ff4a Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Wed, 22 Jul 2026 13:57:01 +0300 Subject: [PATCH 3/3] fix(tree-sitter): include adjacent Dart function bodies --- .../parseSourceCodeDefinitions.dart.spec.ts | 12 ++++++++++++ src/services/tree-sitter/index.ts | 7 ++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts b/src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts index 89e5388dee..d7d681a263 100644 --- a/src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts +++ b/src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts @@ -70,6 +70,18 @@ describe("parseSourceCodeDefinitionsForFile with Dart", () => { expect(result).not.toMatch(/int inner\(\)/) }) + it("includes a multiline top-level function body without duplicating its declaration", async () => { + const content = `int add(int left, int right) { + final result = left + right; + return result; + }` + + const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions) + const addDefinitions = result?.split("\n").filter((line) => line.includes("int add(")) ?? [] + + expect(addDefinitions).toEqual(["1--4 | int add(int left, int right) {"]) + }) + it("should capture common Dart declarations", async () => { const result = await testParseSourceCodeDefinitions("/test/file.dart", sampleDartContent, dartOptions) const definitionLines = result?.split("\n").filter((line) => line.includes(" | ")) ?? [] diff --git a/src/services/tree-sitter/index.ts b/src/services/tree-sitter/index.ts index 5dca6bc072..241094699d 100644 --- a/src/services/tree-sitter/index.ts +++ b/src/services/tree-sitter/index.ts @@ -230,9 +230,14 @@ function processCaptures(captures: QueryCapture[], lines: string[], language: st const definitionNode = name.includes("name") ? node.parent : node if (!definitionNode) return + // Some grammars represent a definition's body as the captured signature's + // next sibling. Include that adjacent body in the definition range. + const trailingDefinitionBody = + definitionNode.nextSibling?.type === "function_body" ? definitionNode.nextSibling : undefined + // Get the start and end lines of the full definition const startLine = definitionNode.startPosition.row - const endLine = definitionNode.endPosition.row + const endLine = trailingDefinitionBody?.endPosition.row ?? definitionNode.endPosition.row const lineCount = endLine - startLine + 1 // Skip components that don't span enough lines