Skip to content

Commit 2634d5c

Browse files
Add Dart support to codebase indexing (#941)
* feat(tree-sitter): add Dart definition support * fix(tree-sitter): address Dart query review feedback * fix(tree-sitter): include adjacent Dart function bodies --------- Co-authored-by: Naved Merchant <naved.merchant@gmail.com>
1 parent e0e7fb9 commit 2634d5c

7 files changed

Lines changed: 313 additions & 3 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
export default `abstract class Animal {
2+
String speak();
3+
4+
Future<String> describe({
5+
required bool verbose,
6+
});
7+
}
8+
9+
class Point {
10+
const Point();
11+
Point.named();
12+
factory Point.origin() => const Point();
13+
14+
Point.fromCoordinates(
15+
int x,
16+
int y,
17+
) : assert(x >= 0),
18+
assert(y >= 0);
19+
20+
factory Point.fromRecord(
21+
({int x, int y}) coordinates,
22+
) => Point.fromCoordinates(
23+
coordinates.x,
24+
coordinates.y,
25+
);
26+
27+
int get x => 0;
28+
set x(int value) {}
29+
30+
Point operator +(Point other) => this;
31+
32+
Point operator [](int index) => this;
33+
34+
static List<T> emptyList<T extends Object>() {
35+
return <T>[];
36+
}
37+
}
38+
39+
mixin Runner {
40+
void run() {
41+
print("running");
42+
}
43+
}
44+
45+
enum Status {
46+
ready,
47+
running;
48+
49+
const Status();
50+
}
51+
52+
class Dog extends Animal with Runner {
53+
final String name;
54+
55+
Dog(this.name);
56+
57+
@override
58+
String speak() {
59+
return "\$name barks";
60+
}
61+
}
62+
63+
extension StringTools on String {
64+
String doubled() {
65+
return this + this;
66+
}
67+
}
68+
69+
extension on int {
70+
int squared() => this * this;
71+
}
72+
73+
extension type UserId(int value) {
74+
UserId.zero() : value = 0;
75+
}
76+
77+
typedef Operation = int Function(int left, int right);
78+
79+
typedef AsyncOperation<T extends Object> = Future<T> Function(
80+
T value, {
81+
required Duration timeout,
82+
});
83+
84+
int get answer => 42;
85+
set answer(int value) {}
86+
87+
int add(int left, int right) {
88+
return left + right;
89+
}
90+
91+
Future<T> retry<T extends Object>(
92+
Future<T> Function() operation, {
93+
int attempts = 3,
94+
}) async {
95+
return operation();
96+
}
97+
98+
Future<void> initialize() async {
99+
await Future<void>.value();
100+
}
101+
102+
Iterable<int> countUpTo(int maximum) sync* {
103+
for (var value = 0; value <= maximum; value++) {
104+
yield value;
105+
}
106+
}
107+
108+
Stream<int> countPeriodically(int maximum) async* {
109+
for (var value = 0; value <= maximum; value++) {
110+
yield value;
111+
}
112+
}
113+
`

src/services/tree-sitter/__tests__/languageParser.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ describe("loadRequiredLanguageParsers", () => {
4949
expect(parsers.kts.query).toBeDefined()
5050
})
5151

52+
it("should load Dart parser for .dart files", async () => {
53+
const parsers = await loadRequiredLanguageParsers(["test.dart"], WASM_DIR)
54+
expect(parsers.dart).toBeDefined()
55+
expect(parsers.dart.query).toBeDefined()
56+
})
57+
5258
it("should throw error for unsupported file extensions", async () => {
5359
const files = ["test.unsupported"]
5460
await expect(loadRequiredLanguageParsers(files, WASM_DIR)).rejects.toThrow("Unsupported language: unsupported")
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { dartQuery } from "../queries"
2+
import { testParseSourceCodeDefinitions } from "./helpers"
3+
import sampleDartContent from "./fixtures/sample-dart"
4+
5+
const dartOptions = {
6+
language: "dart",
7+
wasmFile: "tree-sitter-dart.wasm",
8+
queryString: dartQuery,
9+
extKey: "dart",
10+
}
11+
12+
describe("parseSourceCodeDefinitionsForFile with Dart", () => {
13+
it("captures a redirecting factory constructor", async () => {
14+
const content = `class Logger {
15+
factory Logger() = ConsoleLogger;
16+
}
17+
18+
class ConsoleLogger implements Logger {}`
19+
20+
const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions)
21+
22+
expect(result).toMatch(/\d+--\d+ \|\s*factory Logger\(\) = ConsoleLogger/)
23+
})
24+
25+
it("captures a multiline generative constructor once", async () => {
26+
const content = `class Point {
27+
Point.fromCoordinates(
28+
int x,
29+
int y,
30+
);
31+
}`
32+
33+
const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions)
34+
35+
expect(result?.match(/\d+--\d+ \|\s*Point\.fromCoordinates\(/g)).toHaveLength(1)
36+
})
37+
38+
it("captures a mixin application class", async () => {
39+
const content = `class Base {}
40+
mixin Serializable {}
41+
class SerializableBase = Base with Serializable;`
42+
43+
const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions)
44+
45+
expect(result).toMatch(/\d+--\d+ \|\s*class SerializableBase = Base with Serializable/)
46+
})
47+
48+
it("captures external top-level functions and methods", async () => {
49+
const content = `external int nativeVersion();
50+
51+
class NativeApi {
52+
external String platformName();
53+
}`
54+
55+
const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions)
56+
57+
expect(result).toMatch(/\d+--\d+ \| external int nativeVersion\(\)/)
58+
expect(result).toMatch(/\d+--\d+ \|\s*external String platformName\(\)/)
59+
})
60+
61+
it("does not report local functions as file-level definitions", async () => {
62+
const content = `void outer() {
63+
int inner() => 1;
64+
print(inner());
65+
}`
66+
67+
const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions)
68+
69+
expect(result).toMatch(/\d+--\d+ \| void outer\(\)/)
70+
expect(result).not.toMatch(/int inner\(\)/)
71+
})
72+
73+
it("includes a multiline top-level function body without duplicating its declaration", async () => {
74+
const content = `int add(int left, int right) {
75+
final result = left + right;
76+
return result;
77+
}`
78+
79+
const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions)
80+
const addDefinitions = result?.split("\n").filter((line) => line.includes("int add(")) ?? []
81+
82+
expect(addDefinitions).toEqual(["1--4 | int add(int left, int right) {"])
83+
})
84+
85+
it("should capture common Dart declarations", async () => {
86+
const result = await testParseSourceCodeDefinitions("/test/file.dart", sampleDartContent, dartOptions)
87+
const definitionLines = result?.split("\n").filter((line) => line.includes(" | ")) ?? []
88+
89+
expect(result).toMatch(/\d+--\d+ \| abstract class Animal/)
90+
expect(result).toMatch(/\d+--\d+ \|\s*Future<String> describe/)
91+
expect(result).toMatch(/\d+--\d+ \| class Point/)
92+
expect(result).toMatch(/\d+--\d+ \|\s*const Point\(\)/)
93+
expect(result).toMatch(/\d+--\d+ \|\s*Point\.named\(\)/)
94+
expect(result).toMatch(/\d+--\d+ \|\s*factory Point\.origin\(\)/)
95+
expect(result).toMatch(/\d+--\d+ \|\s*Point\.fromCoordinates\(/)
96+
expect(result).toMatch(/\d+--\d+ \|\s*factory Point\.fromRecord\(/)
97+
expect(result?.match(/\d+--\d+ \| Point\.fromCoordinates\(/g)).toHaveLength(1)
98+
expect(result?.match(/\d+--\d+ \| factory Point\.fromRecord\(/g)).toHaveLength(1)
99+
expect(result).toMatch(/\d+--\d+ \|\s*int get x/)
100+
expect(result).toMatch(/\d+--\d+ \|\s*set x\(int value\)/)
101+
expect(result).toMatch(/\d+--\d+ \|\s*Point operator \+/)
102+
expect(result).toMatch(/\d+--\d+ \|\s*Point operator \[]/)
103+
expect(result).toMatch(/\d+--\d+ \|\s*static List<T> emptyList/)
104+
expect(result).toMatch(/\d+--\d+ \| mixin Runner/)
105+
expect(result).toMatch(/\d+--\d+ \| enum Status/)
106+
expect(result).toMatch(/\d+--\d+ \|\s*const Status\(\)/)
107+
expect(result).toMatch(/\d+--\d+ \| class Dog extends Animal with Runner/)
108+
expect(result).toMatch(/\d+--\d+ \| extension StringTools on String/)
109+
expect(result).toMatch(/\d+--\d+ \| extension on int/)
110+
expect(result).toMatch(/\d+--\d+ \| extension type UserId/)
111+
expect(result).toMatch(/\d+--\d+ \| typedef Operation/)
112+
expect(result).toMatch(/\d+--\d+ \| typedef AsyncOperation/)
113+
expect(result).toMatch(/\d+--\d+ \| int get answer/)
114+
expect(result).toMatch(/\d+--\d+ \| set answer\(int value\)/)
115+
expect(result).toMatch(/\d+--\d+ \|\s*String speak\(\)/)
116+
expect(result).toMatch(/\d+--\d+ \| int add\(int left, int right\)/)
117+
expect(result).toMatch(/\d+--\d+ \| Future<T> retry<T extends Object>/)
118+
expect(result).toMatch(/\d+--\d+ \| Future<void> initialize\(\) async/)
119+
expect(result).toMatch(/\d+--\d+ \| Iterable<int> countUpTo\(int maximum\) sync\*/)
120+
expect(result).toMatch(/\d+--\d+ \| Stream<int> countPeriodically\(int maximum\) async\*/)
121+
expect(definitionLines).toHaveLength(37)
122+
})
123+
})

src/services/tree-sitter/index.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ const extensions = [
9393
"erb",
9494
// Visual Basic .NET
9595
"vb",
96+
// Dart
97+
"dart",
9698
].map((e) => `.${e}`)
9799

98100
export { extensions }
@@ -228,9 +230,14 @@ function processCaptures(captures: QueryCapture[], lines: string[], language: st
228230
const definitionNode = name.includes("name") ? node.parent : node
229231
if (!definitionNode) return
230232

233+
// Some grammars represent a definition's body as the captured signature's
234+
// next sibling. Include that adjacent body in the definition range.
235+
const trailingDefinitionBody =
236+
definitionNode.nextSibling?.type === "function_body" ? definitionNode.nextSibling : undefined
237+
231238
// Get the start and end lines of the full definition
232239
const startLine = definitionNode.startPosition.row
233-
const endLine = definitionNode.endPosition.row
240+
const endLine = trailingDefinitionBody?.endPosition.row ?? definitionNode.endPosition.row
234241
const lineCount = endLine - startLine + 1
235242

236243
// Skip components that don't span enough lines
@@ -270,9 +277,11 @@ function processCaptures(captures: QueryCapture[], lines: string[], language: st
270277
if (node.parent && node.parent.lastChild) {
271278
const contextEnd = node.parent.lastChild.endPosition.row
272279
const contextSpan = contextEnd - node.parent.startPosition.row + 1
280+
const hasDistinctContextStart = node.parent.startPosition.row !== startLine
273281

274-
// Only include context if it spans multiple lines
275-
if (contextSpan >= getMinComponentLines()) {
282+
// Only include context when it adds a distinct source line. A parent
283+
// starting on the definition line would echo the same declaration.
284+
if (hasDistinctContextStart && contextSpan >= getMinComponentLines()) {
276285
// Add the full range first
277286
const rangeKey = `${node.parent.startPosition.row}-${contextEnd}`
278287
if (!processedLines.has(rangeKey)) {

src/services/tree-sitter/languageParser.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
embeddedTemplateQuery,
2929
elispQuery,
3030
elixirQuery,
31+
dartQuery,
3132
} from "./queries"
3233

3334
export interface LanguageParser {
@@ -218,6 +219,10 @@ export async function loadRequiredLanguageParsers(filesToParse: string[], source
218219
language = await loadLanguage("elixir", sourceDirectory)
219220
query = new Query(language, elixirQuery)
220221
break
222+
case "dart":
223+
language = await loadLanguage("dart", sourceDirectory)
224+
query = new Query(language, dartQuery)
225+
break
221226
default:
222227
throw new Error(`Unsupported language: ${ext}`)
223228
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Definition captures adapted from tree-sitter-dart's canonical tags query:
2+
// https://github.com/UserNobody14/tree-sitter-dart/blob/master/queries/tags.scm
3+
export default `
4+
(class_definition
5+
name: (identifier) @name) @definition.class
6+
7+
(class_definition
8+
(mixin_application_class
9+
(identifier) @name)) @definition.class
10+
11+
(type_alias
12+
(type_identifier) @name) @definition.type
13+
14+
(declaration
15+
(function_signature
16+
name: (identifier) @name)) @definition.method
17+
18+
(redirecting_factory_constructor_signature
19+
(identifier) @name) @definition.method
20+
21+
(method_signature) @definition.method
22+
23+
(constructor_signature
24+
name: (identifier) @name) @definition.method
25+
26+
(constant_constructor_signature
27+
(identifier) @name) @definition.method
28+
29+
(mixin_declaration
30+
(mixin)
31+
(identifier) @name) @definition.mixin
32+
33+
(extension_declaration
34+
name: (identifier) @name) @definition.extension
35+
36+
(extension_type_declaration
37+
name: (identifier) @name) @definition.extension
38+
39+
(enum_declaration
40+
name: (identifier) @name) @definition.enum
41+
42+
(program
43+
(getter_signature
44+
name: (identifier) @name) @definition.function)
45+
46+
(program
47+
(setter_signature
48+
name: (identifier) @name) @definition.function)
49+
50+
(program
51+
(function_signature
52+
name: (identifier) @name) @definition.function)
53+
`

src/services/tree-sitter/queries/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ export { zigQuery } from "./zig"
2626
export { default as embeddedTemplateQuery } from "./embedded_template"
2727
export { elispQuery } from "./elisp"
2828
export { scalaQuery } from "./scala"
29+
export { default as dartQuery } from "./dart"

0 commit comments

Comments
 (0)