diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 839168bc..22b4d541 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.7.6", + "version": "2.8.0", "author": { "name": "Lum1104" }, diff --git a/.copilot-plugin/plugin.json b/.copilot-plugin/plugin.json index ea367dd9..3b31e281 100644 --- a/.copilot-plugin/plugin.json +++ b/.copilot-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.7.6", + "version": "2.8.0", "author": { "name": "Lum1104" }, diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index a60b8bc4..840a1343 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "understand-anything", "displayName": "Understand Anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.7.6", + "version": "2.8.0", "author": { "name": "Lum1104" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3eb7e7ab..d47fc52f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: tree-sitter-cpp: specifier: ^0.23.4 version: 0.23.4 + tree-sitter-dart: + specifier: ^1.0.0 + version: 1.0.0 tree-sitter-go: specifier: ^0.25.0 version: 0.25.0 @@ -2406,6 +2409,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nan@2.27.0: + resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2825,6 +2831,9 @@ packages: tree-sitter: optional: true + tree-sitter-dart@1.0.0: + resolution: {integrity: sha512-Ve5YMPJjjGW9LEsO+MngAOibQsw5obFp+bUT41pvwdcXWRwJImOWs3eaPi6AubEiBmc09qvhdvxeIXvxlhMnug==} + tree-sitter-go@0.25.0: resolution: {integrity: sha512-APBc/Dq3xz/e35Xpkhb1blu5UgW+2E3RyGWawZSCNcbGwa7jhSQPS8KsUupuzBla8PCo8+lz9W/JDJjmfRa2tw==} peerDependencies: @@ -5738,6 +5747,8 @@ snapshots: ms@2.1.3: {} + nan@2.27.0: {} + nanoid@3.3.11: {} natural-compare@1.4.0: {} @@ -6230,6 +6241,10 @@ snapshots: node-gyp-build: 4.8.4 tree-sitter-c: 0.23.6 + tree-sitter-dart@1.0.0: + dependencies: + nan: 2.27.0 + tree-sitter-go@0.25.0: dependencies: node-addon-api: 8.6.0 diff --git a/understand-anything-plugin/.claude-plugin/plugin.json b/understand-anything-plugin/.claude-plugin/plugin.json index 839168bc..22b4d541 100644 --- a/understand-anything-plugin/.claude-plugin/plugin.json +++ b/understand-anything-plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.7.6", + "version": "2.8.0", "author": { "name": "Lum1104" }, diff --git a/understand-anything-plugin/package.json b/understand-anything-plugin/package.json index 85f70f01..67b96339 100644 --- a/understand-anything-plugin/package.json +++ b/understand-anything-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@understand-anything/skill", - "version": "2.7.6", + "version": "2.8.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/understand-anything-plugin/packages/core/package.json b/understand-anything-plugin/packages/core/package.json index c4cd4f32..9ffdeb13 100644 --- a/understand-anything-plugin/packages/core/package.json +++ b/understand-anything-plugin/packages/core/package.json @@ -41,6 +41,7 @@ "ignore": "^7.0.5", "tree-sitter-c-sharp": "^0.23.1", "tree-sitter-cpp": "^0.23.4", + "tree-sitter-dart": "^1.0.0", "tree-sitter-go": "^0.25.0", "tree-sitter-java": "^0.23.5", "tree-sitter-javascript": "^0.25.0", diff --git a/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts b/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts index 7a3c7749..55318599 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts @@ -49,10 +49,10 @@ describe("LanguageRegistry", () => { }); describe("createDefault", () => { - it("registers all 40 built-in language configs", () => { + it("registers all 41 built-in language configs", () => { const registry = LanguageRegistry.createDefault(); const all = registry.getAllLanguages(); - expect(all.length).toBe(40); + expect(all.length).toBe(41); }); it("maps all expected extensions", () => { @@ -72,6 +72,7 @@ describe("LanguageRegistry", () => { expect(registry.getByExtension(".h")?.id).toBe("c"); expect(registry.getByExtension(".lua")?.id).toBe("lua"); expect(registry.getByExtension(".js")?.id).toBe("javascript"); + expect(registry.getByExtension(".dart")?.id).toBe("dart"); }); it("has no duplicate extension mappings across configs", () => { diff --git a/understand-anything-plugin/packages/core/src/languages/configs/dart.ts b/understand-anything-plugin/packages/core/src/languages/configs/dart.ts new file mode 100644 index 00000000..4576c01a --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/dart.ts @@ -0,0 +1,30 @@ +import type { LanguageConfig } from "../types.js"; + +export const dartConfig = { + id: "dart", + displayName: "Dart", + extensions: [".dart"], + treeSitter: { + wasmPackage: "tree-sitter-dart", + wasmFile: "tree-sitter-dart.wasm", + }, + concepts: [ + "null safety", + "futures and async/await", + "streams", + "mixins", + "extensions", + "isolates", + "named/optional parameters", + "factory constructors", + "const constructors", + "generics", + "sealed classes and pattern matching", + ], + filePatterns: { + entryPoints: ["lib/main.dart", "bin/main.dart", "lib/*.dart"], + barrels: ["lib/*.dart"], + tests: ["test/**/*_test.dart", "integration_test/**/*.dart"], + config: ["pubspec.yaml", "analysis_options.yaml", "build.yaml"], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/index.ts b/understand-anything-plugin/packages/core/src/languages/configs/index.ts index 4893c4d5..08d25c68 100644 --- a/understand-anything-plugin/packages/core/src/languages/configs/index.ts +++ b/understand-anything-plugin/packages/core/src/languages/configs/index.ts @@ -13,6 +13,7 @@ import { cConfig } from "./c.js"; import { cppConfig } from "./cpp.js"; import { csharpConfig } from "./csharp.js"; import { luaConfig } from "./lua.js"; +import { dartConfig } from "./dart.js"; // Non-code language configs import { markdownConfig } from "./markdown.js"; import { yamlConfig } from "./yaml.js"; @@ -57,6 +58,7 @@ export const builtinLanguageConfigs: LanguageConfig[] = [ cConfig, cppConfig, csharpConfig, + dartConfig, // Non-code languages markdownConfig, yamlConfig, @@ -102,6 +104,7 @@ export { cConfig, cppConfig, csharpConfig, + dartConfig, // Non-code languages markdownConfig, yamlConfig, diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/dart-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/dart-extractor.test.ts new file mode 100644 index 00000000..07abad2c --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/dart-extractor.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { existsSync } from "node:fs"; +import { DartExtractor } from "../dart-extractor.js"; + +const require = createRequire(import.meta.url); + +let Parser: any; +let Language: any; +let dartLang: any; + +beforeAll(async () => { + const mod = await import("web-tree-sitter"); + Parser = mod.Parser; + Language = mod.Language; + await Parser.init(); + + // Prefer the bundled WASM (rebuilt against the current web-tree-sitter ABI) + // since the npm tree-sitter-dart 1.0.0 release ships an outdated dylink + // format that fails to load. Fall back to the npm copy if the bundle is + // not present (e.g. when running outside the source tree). + const here = fileURLToPath(import.meta.url); + const bundled = resolve( + dirname(here), + "..", "..", "..", "..", "wasm-grammars", "tree-sitter-dart.wasm", + ); + const wasmPath = existsSync(bundled) + ? bundled + : require.resolve("tree-sitter-dart/tree-sitter-dart.wasm"); + dartLang = await Language.load(wasmPath); +}); + +function parse(code: string) { + const parser = new Parser(); + parser.setLanguage(dartLang); + const tree = parser.parse(code); + const root = tree.rootNode; + return { tree, parser, root }; +} + +describe("DartExtractor", () => { + const extractor = new DartExtractor(); + + it("has correct languageIds", () => { + expect(extractor.languageIds).toEqual(["dart"]); + }); + + describe("extractStructure - imports", () => { + it("extracts package imports with alias and last-path-segment fallback", () => { + const { tree, parser, root } = parse(` +import 'package:flutter/material.dart'; +import 'package:foo/bar.dart' as bar; +import 'dart:async'; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(3); + + expect(result.imports[0].source).toBe("package:flutter/material.dart"); + expect(result.imports[0].specifiers).toEqual(["material"]); + + expect(result.imports[1].source).toBe("package:foo/bar.dart"); + expect(result.imports[1].specifiers).toEqual(["bar"]); + + expect(result.imports[2].source).toBe("dart:async"); + expect(result.imports[2].specifiers).toEqual(["async"]); + + tree.delete(); + parser.delete(); + }); + + it("records exports as star imports", () => { + const { tree, parser, root } = parse(` +export 'package:foo/bar.dart'; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("package:foo/bar.dart"); + expect(result.imports[0].specifiers).toEqual(["*"]); + + tree.delete(); + parser.delete(); + }); + }); + + describe("extractStructure - top-level functions", () => { + it("extracts function name, params, and return type", () => { + const { tree, parser, root } = parse(` +String greet(String name, int age) { + return 'hi'; +} + +void main() {} +`); + const result = extractor.extractStructure(root); + + expect(result.functions.length).toBeGreaterThanOrEqual(2); + const greet = result.functions.find((f) => f.name === "greet"); + const main = result.functions.find((f) => f.name === "main"); + + expect(greet).toBeDefined(); + expect(greet!.params).toEqual(["name", "age"]); + expect(greet!.returnType).toBe("String"); + + expect(main).toBeDefined(); + expect(main!.returnType).toBe("void"); + + // Public top-level functions are exported (Dart: not starting with _) + expect(result.exports.map((e) => e.name)).toContain("greet"); + expect(result.exports.map((e) => e.name)).toContain("main"); + + tree.delete(); + parser.delete(); + }); + + it("treats _-prefixed names as library-private (not exported)", () => { + const { tree, parser, root } = parse(` +void _helper() {} +void publicFn() {} +`); + const result = extractor.extractStructure(root); + const exportNames = result.exports.map((e) => e.name); + + expect(exportNames).toContain("publicFn"); + expect(exportNames).not.toContain("_helper"); + + tree.delete(); + parser.delete(); + }); + }); + + describe("extractStructure - classes", () => { + it("extracts class with methods and properties", () => { + const { tree, parser, root } = parse(` +class Counter { + int value = 0; + String label; + + Counter(this.label); + + void increment() { + value++; + } + + int get current => value; +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + const counter = result.classes[0]; + expect(counter.name).toBe("Counter"); + expect(counter.methods).toContain("increment"); + // Constructor name should appear too + expect(counter.methods.some((m) => m.includes("Counter"))).toBe(true); + // Field names from `int value = 0;` and `String label;` + // Either `value` or `label` should be present (parser shape varies). + expect( + counter.properties.includes("value") || + counter.properties.includes("label"), + ).toBe(true); + + expect(result.exports.map((e) => e.name)).toContain("Counter"); + + tree.delete(); + parser.delete(); + }); + + it("extracts mixin and enum", () => { + const { tree, parser, root } = parse(` +mixin Walker { + void walk() {} +} + +enum Color { red, green, blue } +`); + const result = extractor.extractStructure(root); + + const walker = result.classes.find((c) => c.name === "Walker"); + const color = result.classes.find((c) => c.name === "Color"); + + expect(walker).toBeDefined(); + expect(walker!.methods).toContain("walk"); + + expect(color).toBeDefined(); + // enum constants captured as properties + expect(color!.properties).toEqual( + expect.arrayContaining(["red", "green", "blue"]), + ); + + tree.delete(); + parser.delete(); + }); + }); + + describe("extractCallGraph", () => { + it("returns empty array (semantic call analysis is delegated to LLM)", () => { + const { tree, parser, root } = parse(` +void main() { + print('hi'); +} +`); + const result = extractor.extractCallGraph(root); + expect(result).toEqual([]); + + tree.delete(); + parser.delete(); + }); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/dart-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/dart-extractor.ts new file mode 100644 index 00000000..1ef12fd5 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/dart-extractor.ts @@ -0,0 +1,487 @@ +import type { StructuralAnalysis, CallGraphEntry } from "../../types.js"; +import type { LanguageExtractor, TreeSitterNode } from "./types.js"; +import { findChild, findChildren, traverse } from "./base-extractor.js"; + +/** + * Dart extractor for tree-sitter structural analysis. + * + * Grammar reference: tree-sitter-dart (UserNobody14/tree-sitter-dart). + * + * Top-level mapping: + * - class_definition / mixin_declaration / extension_declaration → classes + * - enum_declaration → classes (Dart enums can carry methods/fields) + * - top-level `function_signature` → functions + * - library_import → imports (source is the URI text, trimmed of quotes) + * - library_export is recorded as an import-style edge with an "*" specifier + * so the graph reviewer can still detect re-exports. + * + * Visibility convention (Dart): names starting with "_" are library-private. + * Anything else is exported. + */ +export class DartExtractor implements LanguageExtractor { + readonly languageIds = ["dart"]; + + extractStructure(rootNode: TreeSitterNode): StructuralAnalysis { + const functions: StructuralAnalysis["functions"] = []; + const classes: StructuralAnalysis["classes"] = []; + const imports: StructuralAnalysis["imports"] = []; + const exports: StructuralAnalysis["exports"] = []; + + // Walk top-level children of the program node. tree-sitter-dart represents + // top-level functions as a `function_signature` followed by a sibling + // `function_body`, so we iterate index-by-index to pair them. + for (let i = 0; i < rootNode.childCount; i++) { + const node = rootNode.child(i); + if (!node) continue; + + switch (node.type) { + case "class_definition": + this.extractClass(node, classes, exports); + break; + + case "mixin_declaration": + this.extractMixin(node, classes, exports); + break; + + case "extension_declaration": + this.extractExtension(node, classes, exports); + break; + + case "enum_declaration": + this.extractEnum(node, classes, exports); + break; + + case "function_signature": { + // Top-level function: pair with the trailing function_body if present + const next = rootNode.child(i + 1); + const endLine = + next && next.type === "function_body" + ? next.endPosition.row + 1 + : node.endPosition.row + 1; + this.extractTopLevelFunction(node, endLine, functions, exports); + break; + } + + case "getter_signature": + case "setter_signature": { + const nameNode = node.childForFieldName("name"); + if (nameNode) { + const next = rootNode.child(i + 1); + const endLine = + next && next.type === "function_body" + ? next.endPosition.row + 1 + : node.endPosition.row + 1; + functions.push({ + name: nameNode.text, + lineRange: [node.startPosition.row + 1, endLine], + params: [], + }); + if (isExported(nameNode.text)) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + break; + } + + case "library_import": + this.extractImport(node, imports); + break; + + case "library_export": + this.extractExportDirective(node, imports); + break; + + case "import_or_export": { + // tree-sitter-dart wraps imports/exports in an `import_or_export` + // node at the program level. Unwrap and dispatch. + const lib = + findChild(node, "library_import") ?? + findChild(node, "library_export"); + if (!lib) break; + if (lib.type === "library_import") { + this.extractImport(lib, imports); + } else { + this.extractExportDirective(lib, imports); + } + break; + } + } + } + + return { functions, classes, imports, exports }; + } + + // tree-sitter-dart does not expose a single `call_expression` node; calls are + // expressed via chained selectors (`identifier` + `argument_part`). Producing + // a clean caller→callee mapping requires nontrivial heuristics, so we return + // [] here and let the LLM-side analysis cover semantic call relationships. + extractCallGraph(_rootNode: TreeSitterNode): CallGraphEntry[] { + return []; + } + + // ---- Private helpers ---- + + private extractClass( + node: TreeSitterNode, + classes: StructuralAnalysis["classes"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const bodyNode = node.childForFieldName("body"); + const { methods, properties } = bodyNode + ? this.extractClassBody(bodyNode) + : { methods: [], properties: [] }; + + classes.push({ + name: nameNode.text, + lineRange: [node.startPosition.row + 1, node.endPosition.row + 1], + methods, + properties, + }); + + if (isExported(nameNode.text)) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractMixin( + node: TreeSitterNode, + classes: StructuralAnalysis["classes"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = findChild(node, "identifier"); + if (!nameNode) return; + + const bodyNode = findChild(node, "class_body"); + const { methods, properties } = bodyNode + ? this.extractClassBody(bodyNode) + : { methods: [], properties: [] }; + + classes.push({ + name: nameNode.text, + lineRange: [node.startPosition.row + 1, node.endPosition.row + 1], + methods, + properties, + }); + + if (isExported(nameNode.text)) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractExtension( + node: TreeSitterNode, + classes: StructuralAnalysis["classes"], + exports: StructuralAnalysis["exports"], + ): void { + // Anonymous extensions are allowed (`extension on Foo { ... }`); they have + // no `identifier` direct child, so we skip the export but still record + // the structure under a synthetic name. + const nameNode = findChild(node, "identifier"); + const bodyNode = node.childForFieldName("body"); + const { methods, properties } = bodyNode + ? this.extractClassBody(bodyNode) + : { methods: [], properties: [] }; + + const name = nameNode ? nameNode.text : `_AnonymousExtension_${node.startPosition.row + 1}`; + + classes.push({ + name, + lineRange: [node.startPosition.row + 1, node.endPosition.row + 1], + methods, + properties, + }); + + if (nameNode && isExported(nameNode.text)) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractEnum( + node: TreeSitterNode, + classes: StructuralAnalysis["classes"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const bodyNode = node.childForFieldName("body"); + const properties: string[] = []; + if (bodyNode) { + // Enum constants live in the body; collect identifier names as properties. + const constants = findChildren(bodyNode, "enum_constant"); + for (const c of constants) { + const id = findChild(c, "identifier"); + if (id) properties.push(id.text); + } + } + + classes.push({ + name: nameNode.text, + lineRange: [node.startPosition.row + 1, node.endPosition.row + 1], + methods: [], + properties, + }); + + if (isExported(nameNode.text)) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractClassBody(bodyNode: TreeSitterNode): { + methods: string[]; + properties: string[]; + } { + const methods: string[] = []; + const properties: string[] = []; + + // class_body children: declaration | method_signature | function_body | annotation + for (let i = 0; i < bodyNode.childCount; i++) { + const child = bodyNode.child(i); + if (!child) continue; + + if (child.type === "method_signature") { + // method_signature → wraps function/getter/setter/operator/constructor signatures. + // We pull the inner signature's name. + const inner = + findChild(child, "function_signature") ?? + findChild(child, "getter_signature") ?? + findChild(child, "setter_signature") ?? + findChild(child, "operator_signature") ?? + findChild(child, "constructor_signature") ?? + findChild(child, "factory_constructor_signature"); + if (inner) { + const nameNode = inner.childForFieldName("name"); + if (nameNode) { + methods.push(nameNode.text); + } else { + // factory_constructor_signature has no field name; fallback to first identifier + const fallback = findChild(inner, "identifier"); + if (fallback) methods.push(fallback.text); + } + } + } else if (child.type === "declaration") { + // declarations cover fields and inline method definitions. + // - function_signature inside declaration → method + // - identifier list / initialized_identifier → field + const fnSig = findChild(child, "function_signature"); + if (fnSig) { + const nameNode = fnSig.childForFieldName("name"); + if (nameNode) methods.push(nameNode.text); + } + + const getter = findChild(child, "getter_signature"); + if (getter) { + const nameNode = getter.childForFieldName("name"); + if (nameNode) methods.push(nameNode.text); + } + const setter = findChild(child, "setter_signature"); + if (setter) { + const nameNode = setter.childForFieldName("name"); + if (nameNode) methods.push(nameNode.text); + } + + const ctor = findChild(child, "constructor_signature"); + if (ctor) { + const nameNode = ctor.childForFieldName("name"); + if (nameNode) methods.push(nameNode.text); + else { + const fallback = findChild(ctor, "identifier"); + if (fallback) methods.push(fallback.text); + } + } + + const factoryCtor = findChild(child, "factory_constructor_signature"); + if (factoryCtor) { + // factory constructors: `factory Foo()` or `factory Foo.named()` + const ids = findChildren(factoryCtor, "identifier"); + if (ids.length > 0) methods.push(ids.map((n) => n.text).join(".")); + } + + // Field declarations: collect identifiers from initialized_identifier_list / + // static_final_declaration_list / initialized_identifier descendants. + traverse(child, (n) => { + if (n.type === "initialized_identifier") { + const id = findChild(n, "identifier"); + if (id) properties.push(id.text); + } else if (n.type === "static_final_declaration") { + const id = findChild(n, "identifier"); + if (id) properties.push(id.text); + } + }); + } + } + + return { methods, properties }; + } + + private extractTopLevelFunction( + sigNode: TreeSitterNode, + endLine: number, + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = sigNode.childForFieldName("name"); + if (!nameNode) return; + + const params = extractParamNames(findChild(sigNode, "formal_parameter_list")); + const returnType = extractReturnType(sigNode); + + functions.push({ + name: nameNode.text, + lineRange: [sigNode.startPosition.row + 1, endLine], + params, + returnType, + }); + + if (isExported(nameNode.text)) { + exports.push({ + name: nameNode.text, + lineNumber: sigNode.startPosition.row + 1, + }); + } + } + + private extractImport( + node: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + const spec = findChild(node, "import_specification"); + if (!spec) return; + + const source = extractUri(spec); + if (!source) return; + + // Look for `as ` identifier or fall back to the URI's last path + // segment (without extension). For Dart, `package:flutter/material.dart` + // becomes "material" — this matches how the language registry treats + // unaliased imports across other extractors. + const alias = findChild(spec, "identifier"); + let specifier: string; + if (alias) { + specifier = alias.text; + } else { + // For `package:foo/bar.dart` → "bar"; for `dart:async` → "async"; + // for bare `dart:` schemes without a slash, split on ":" first. + const afterColon = source.includes(":") ? source.split(":").pop()! : source; + const last = afterColon.split("/").pop() ?? afterColon; + specifier = last.replace(/\.dart$/i, ""); + } + + imports.push({ + source, + specifiers: [specifier], + lineNumber: node.startPosition.row + 1, + }); + } + + private extractExportDirective( + node: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + const source = extractUri(node); + if (!source) return; + imports.push({ + source, + specifiers: ["*"], + lineNumber: node.startPosition.row + 1, + }); + } +} + +// ---- module-private helpers ---- + +function isExported(name: string): boolean { + return !name.startsWith("_"); +} + +function extractUri(parent: TreeSitterNode): string | null { + // import_specification or library_export children include `configurable_uri` + // or `uri`. Both ultimately wrap a string literal. + const configurable = findChild(parent, "configurable_uri"); + const uri = configurable + ? findChild(configurable, "uri") ?? configurable + : findChild(parent, "uri"); + if (!uri) return null; + // `uri` text is typically a string literal: 'package:foo/bar.dart' + const stringLit = findChild(uri, "string_literal") ?? uri; + return stripQuotes(stringLit.text); +} + +function stripQuotes(text: string): string { + return text.replace(/^['"]|['"]$/g, "").replace(/^['"]{3}|['"]{3}$/g, ""); +} + +function extractParamNames( + paramsNode: TreeSitterNode | null, +): string[] { + if (!paramsNode) return []; + const names: string[] = []; + + const collect = (n: TreeSitterNode) => { + for (let i = 0; i < n.childCount; i++) { + const child = n.child(i); + if (!child) continue; + if (child.type === "formal_parameter") { + const nameField = child.childForFieldName("name"); + if (nameField) { + names.push(nameField.text); + } else { + // Fallback: last identifier child is usually the param name + const ids = findChildren(child, "identifier"); + if (ids.length > 0) names.push(ids[ids.length - 1]!.text); + } + } else if ( + child.type === "optional_formal_parameters" || + child.type === "named_formal_parameters" || + child.type === "default_formal_parameter" || + child.type === "default_named_parameter" + ) { + // Recurse into wrappers for `[a, b]` / `{a, b}` / `a = 1` shapes. + collect(child); + } + } + }; + + collect(paramsNode); + return names; +} + +function extractReturnType(sigNode: TreeSitterNode): string | undefined { + // function_signature children include the return type as `type_identifier`, + // `void_type`, or `function_type` placed before the `name` identifier. + // We grab the first such typed child if present. + for (let i = 0; i < sigNode.childCount; i++) { + const child = sigNode.child(i); + if (!child) continue; + if ( + child.type === "type_identifier" || + child.type === "void_type" || + child.type === "function_type" + ) { + return child.text; + } + if (child.type === "identifier") { + // Reached the name field — no return type was present before it. + return undefined; + } + } + return undefined; +} diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts index f148c61c..96c62620 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts @@ -9,6 +9,7 @@ export { RubyExtractor } from "./ruby-extractor.js"; export { PhpExtractor } from "./php-extractor.js"; export { CppExtractor } from "./cpp-extractor.js"; export { CSharpExtractor } from "./csharp-extractor.js"; +export { DartExtractor } from "./dart-extractor.js"; import type { LanguageExtractor } from "./types.js"; import { TypeScriptExtractor } from "./typescript-extractor.js"; @@ -20,6 +21,7 @@ import { RubyExtractor } from "./ruby-extractor.js"; import { PhpExtractor } from "./php-extractor.js"; import { CppExtractor } from "./cpp-extractor.js"; import { CSharpExtractor } from "./csharp-extractor.js"; +import { DartExtractor } from "./dart-extractor.js"; export const builtinExtractors: LanguageExtractor[] = [ new TypeScriptExtractor(), @@ -31,4 +33,5 @@ export const builtinExtractors: LanguageExtractor[] = [ new PhpExtractor(), new CppExtractor(), new CSharpExtractor(), + new DartExtractor(), ]; diff --git a/understand-anything-plugin/packages/core/src/plugins/tree-sitter-plugin.ts b/understand-anything-plugin/packages/core/src/plugins/tree-sitter-plugin.ts index 65203d25..c9fe1f60 100644 --- a/understand-anything-plugin/packages/core/src/plugins/tree-sitter-plugin.ts +++ b/understand-anything-plugin/packages/core/src/plugins/tree-sitter-plugin.ts @@ -1,5 +1,7 @@ import { createRequire } from "node:module"; import { dirname, resolve, extname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { existsSync } from "node:fs"; import type { AnalyzerPlugin, StructuralAnalysis, @@ -13,6 +15,29 @@ import { builtinExtractors } from "./extractors/index.js"; // web-tree-sitter uses CJS internally; we need createRequire for .wasm resolution const require = createRequire(import.meta.url); +/** + * Resolve the path to a bundled grammar WASM kept inside this package under + * `wasm-grammars/`. Used as a fallback when the npm-published grammar is + * missing or its WASM is incompatible with the current web-tree-sitter ABI + * (e.g. tree-sitter-dart 1.0.0 ships an outdated dylink format). + * + * Returns null when no bundled copy is available for the given filename. + */ +function bundledWasmPath(wasmFile: string): string | null { + // import.meta.url resolves to dist/plugins/tree-sitter-plugin.js at runtime + // and src/plugins/tree-sitter-plugin.ts during tests. The bundle dir lives + // two levels up from either location (dist/plugins/.. → dist/..; same for src). + const here = fileURLToPath(import.meta.url); + const candidates = [ + resolve(dirname(here), "..", "..", "wasm-grammars", wasmFile), + resolve(dirname(here), "..", "wasm-grammars", wasmFile), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + return null; +} + type TreeSitterParser = import("web-tree-sitter").Parser; type TreeSitterLanguage = import("web-tree-sitter").Language; @@ -139,30 +164,53 @@ export class TreeSitterPlugin implements AnalyzerPlugin { if (!config.treeSitter) continue; const loadGrammar = async () => { + // Try the npm-published grammar first; if its WASM is missing or + // fails to load (e.g. the npm package ships an outdated dylink + // format), fall back to a copy bundled inside this package under + // `wasm-grammars/`. + const tryLoad = async (path: string) => LanguageCls.load(path); + const bundledPath = bundledWasmPath(config.treeSitter!.wasmFile); + + let loaded = false; try { const wasmPath = require.resolve( `${config.treeSitter!.wasmPackage}/${config.treeSitter!.wasmFile}`, ); - const lang = await LanguageCls.load(wasmPath); + const lang = await tryLoad(wasmPath); this._languages.set(config.id, lang); + loaded = true; + } catch { + // Will try bundled fallback below. + } - // Special handling for TypeScript: also load TSX grammar - if (config.id === "typescript") { - try { - const tsxWasm = require.resolve( - `${config.treeSitter!.wasmPackage}/tree-sitter-tsx.wasm`, - ); - const tsxLang = await LanguageCls.load(tsxWasm); - this._languages.set("tsx", tsxLang); - } catch { - // TSX grammar not available; .tsx files will fall back to TS grammar - } + if (!loaded && bundledPath && existsSync(bundledPath)) { + try { + const lang = await tryLoad(bundledPath); + this._languages.set(config.id, lang); + loaded = true; + } catch { + // fall through to debug log } - } catch { - // Grammar not available — this language will be skipped gracefully + } + + if (!loaded) { console.debug?.( `tree-sitter: Could not load grammar for ${config.id}, skipping structural analysis`, ); + return; + } + + // Special handling for TypeScript: also load TSX grammar + if (config.id === "typescript") { + try { + const tsxWasm = require.resolve( + `${config.treeSitter!.wasmPackage}/tree-sitter-tsx.wasm`, + ); + const tsxLang = await tryLoad(tsxWasm); + this._languages.set("tsx", tsxLang); + } catch { + // TSX grammar not available; .tsx files will fall back to TS grammar + } } }; diff --git a/understand-anything-plugin/packages/core/wasm-grammars/tree-sitter-dart.wasm b/understand-anything-plugin/packages/core/wasm-grammars/tree-sitter-dart.wasm new file mode 100755 index 00000000..4154b05a Binary files /dev/null and b/understand-anything-plugin/packages/core/wasm-grammars/tree-sitter-dart.wasm differ