Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/services/tree-sitter/__tests__/fixtures/sample-dart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
export default `abstract class Animal {
String speak();

Future<String> 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<T> emptyList<T extends Object>() {
return <T>[];
}
}

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<T extends Object> = Future<T> Function(
T value, {
required Duration timeout,
});

int get answer => 42;
set answer(int value) {}

int add(int left, int right) {
return left + right;
}

Future<T> retry<T extends Object>(
Future<T> Function() operation, {
int attempts = 3,
}) async {
return operation();
}

Future<void> initialize() async {
await Future<void>.value();
}

Iterable<int> countUpTo(int maximum) sync* {
for (var value = 0; value <= maximum; value++) {
yield value;
}
}

Stream<int> countPeriodically(int maximum) async* {
for (var value = 0; value <= maximum; value++) {
yield value;
}
}
`
6 changes: 6 additions & 0 deletions src/services/tree-sitter/__tests__/languageParser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<T> 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<T> retry<T extends Object>/)
expect(result).toMatch(/\d+--\d+ \| Future<void> initialize\(\) async/)
expect(result).toMatch(/\d+--\d+ \| Iterable<int> countUpTo\(int maximum\) sync\*/)
expect(result).toMatch(/\d+--\d+ \| Stream<int> countPeriodically\(int maximum\) async\*/)
expect(definitionLines).toHaveLength(37)
})
})
15 changes: 12 additions & 3 deletions src/services/tree-sitter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ const extensions = [
"erb",
// Visual Basic .NET
"vb",
// Dart
"dart",
].map((e) => `.${e}`)

export { extensions }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down
5 changes: 5 additions & 0 deletions src/services/tree-sitter/languageParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
embeddedTemplateQuery,
elispQuery,
elixirQuery,
dartQuery,
} from "./queries"

export interface LanguageParser {
Expand Down Expand Up @@ -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}`)
}
Expand Down
53 changes: 53 additions & 0 deletions src/services/tree-sitter/queries/dart.ts
Original file line number Diff line number Diff line change
@@ -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
Comment thread
WebMad marked this conversation as resolved.

(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)
`
1 change: 1 addition & 0 deletions src/services/tree-sitter/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading