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..d09f12ac35 --- /dev/null +++ b/src/services/tree-sitter/__tests__/fixtures/sample-dart.ts @@ -0,0 +1,113 @@ +export default `abstract class Animal { + String speak(); + + Future describe({ + required bool verbose, + }); +} + +class Point { + const 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 { + 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 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__/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..d7d681a263 --- /dev/null +++ b/src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts @@ -0,0 +1,123 @@ +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("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("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(" | ")) ?? [] + + 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 da20a07de7..241094699d 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 } @@ -228,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 @@ -270,9 +277,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/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..d4c67a05ac --- /dev/null +++ b/src/services/tree-sitter/queries/dart.ts @@ -0,0 +1,53 @@ +// 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 + +(class_definition + (mixin_application_class + (identifier) @name)) @definition.class + +(type_alias + (type_identifier) @name) @definition.type + +(declaration + (function_signature + name: (identifier) @name)) @definition.method + +(redirecting_factory_constructor_signature + (identifier) @name) @definition.method + +(method_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) + +(program + (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"