From 6cba6de658c1b88743e69d6ee446dbf6b1827a0b Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 17:30:38 +0800 Subject: [PATCH 01/18] refactor: replace hardcoded EXTENSION_LANGUAGE map in GraphBuilder with LanguageRegistry GraphBuilder maintained its own ~60-line extension-to-language mapping that duplicated and could diverge from the canonical LanguageRegistry. Now delegates language detection to LanguageRegistry.getForFile(), eliminating the duplication and ensuring new language configs are automatically picked up everywhere. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/src/analyzer/graph-builder.ts | 81 +++---------------- 1 file changed, 11 insertions(+), 70 deletions(-) diff --git a/understand-anything-plugin/packages/core/src/analyzer/graph-builder.ts b/understand-anything-plugin/packages/core/src/analyzer/graph-builder.ts index d34d723..16da567 100644 --- a/understand-anything-plugin/packages/core/src/analyzer/graph-builder.ts +++ b/understand-anything-plugin/packages/core/src/analyzer/graph-builder.ts @@ -10,6 +10,7 @@ import type { ResourceInfo, SectionInfo, } from "../types.js"; +import { LanguageRegistry } from "../languages/language-registry.js"; interface FileMeta { summary: string; @@ -56,72 +57,6 @@ const KIND_TO_NODE_TYPE: Record = { output: "config", }; -const EXTENSION_LANGUAGE: Record = { - // Code languages - ".ts": "typescript", - ".tsx": "typescript", - ".js": "javascript", - ".jsx": "javascript", - ".mjs": "javascript", - ".cjs": "javascript", - ".py": "python", - ".rb": "ruby", - ".go": "go", - ".rs": "rust", - ".java": "java", - ".kt": "kotlin", - ".swift": "swift", - ".c": "c", - ".cpp": "cpp", - ".h": "c", - ".hpp": "cpp", - ".cs": "csharp", - ".php": "php", - ".lua": "lua", - // Non-code languages - ".sh": "shell", - ".bash": "shell", - ".zsh": "shell", - ".json": "json", - ".jsonc": "json", - ".yaml": "yaml", - ".yml": "yaml", - ".toml": "toml", - ".xml": "xml", - ".html": "html", - ".htm": "html", - ".css": "css", - ".scss": "css", - ".less": "css", - ".md": "markdown", - ".mdx": "markdown", - ".sql": "sql", - ".graphql": "graphql", - ".gql": "graphql", - ".proto": "protobuf", - ".tf": "terraform", - ".tfvars": "terraform", - ".mk": "makefile", - ".env": "env", - ".csv": "csv", - ".tsv": "csv", - ".rst": "restructuredtext", - ".ps1": "powershell", - ".psm1": "powershell", - ".psd1": "powershell", - ".bat": "batch", - ".cmd": "batch", - ".txt": "plaintext", - ".svg": "xml", -}; - -function detectLanguage(filePath: string): string { - const lastDot = filePath.lastIndexOf("."); - if (lastDot === -1) return "unknown"; - const ext = filePath.slice(lastDot).toLowerCase(); - return EXTENSION_LANGUAGE[ext] ?? "unknown"; -} - export class GraphBuilder { private readonly nodes: GraphNode[] = []; private readonly edges: GraphEdge[] = []; @@ -130,10 +65,16 @@ export class GraphBuilder { private readonly edgeKeys = new Set(); private readonly projectName: string; private readonly gitHash: string; + private readonly languageRegistry: LanguageRegistry; - constructor(projectName: string, gitHash: string) { + constructor(projectName: string, gitHash: string, languageRegistry?: LanguageRegistry) { this.projectName = projectName; this.gitHash = gitHash; + this.languageRegistry = languageRegistry ?? LanguageRegistry.createDefault(); + } + + private detectLanguage(filePath: string): string { + return this.languageRegistry.getForFile(filePath)?.id ?? "unknown"; } private static basename(filePath: string): string { @@ -141,7 +82,7 @@ export class GraphBuilder { } addFile(filePath: string, meta: FileMeta): void { - const lang = detectLanguage(filePath); + const lang = this.detectLanguage(filePath); if (lang !== "unknown") { this.languages.add(lang); } @@ -166,7 +107,7 @@ export class GraphBuilder { analysis: StructuralAnalysis, meta: FileAnalysisMeta, ): void { - const lang = detectLanguage(filePath); + const lang = this.detectLanguage(filePath); if (lang !== "unknown") { this.languages.add(lang); } @@ -267,7 +208,7 @@ export class GraphBuilder { } addNonCodeFile(filePath: string, meta: NonCodeFileMeta): string { - const lang = detectLanguage(filePath); + const lang = this.detectLanguage(filePath); if (lang !== "unknown") this.languages.add(lang); const name = GraphBuilder.basename(filePath); const id = `${meta.nodeType ?? "file"}:${filePath}`; From b2fb97b8840ff31badc2bd8983abbca05f54e5ac Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 18:01:57 +0800 Subject: [PATCH 02/18] docs: add implementation plan for multi-language tree-sitter extractor architecture Plan covers: LanguageExtractor interface, per-language extractors for 8 languages (Python, Go, Rust, Java, Ruby, PHP, C/C++, C#), bundled extract-structure.mjs script replacing LLM-generated regex in file-analyzer agent, and PluginRegistry enhancements. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-15-language-extractors-impl.md | 856 ++++++++++++++++++ 1 file changed, 856 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-15-language-extractors-impl.md diff --git a/docs/superpowers/plans/2026-04-15-language-extractors-impl.md b/docs/superpowers/plans/2026-04-15-language-extractors-impl.md new file mode 100644 index 0000000..412ba57 --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-language-extractors-impl.md @@ -0,0 +1,856 @@ +# Language-Specific Extractor Architecture Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** (1) Decouple AST extraction logic from TS/JS-specific node types so 8 additional code languages (Python, Go, Rust, Java, Ruby, PHP, C/C++, C#) get tree-sitter-powered structural analysis. Swift and Kotlin are excluded — no WASM grammar packages available. (2) Replace the file-analyzer agent's ad-hoc regex script generation with a deterministic, pre-built tree-sitter extraction script. + +**Architecture:** Introduce a `LanguageExtractor` interface that each language implements. `TreeSitterPlugin` delegates extraction to the registered extractor for the file's language. A bundled `extract-structure.mjs` script in `skills/understand/` uses `PluginRegistry` (which includes both `TreeSitterPlugin` and the non-code parsers) to provide deterministic structural extraction for the file-analyzer agent — replacing the current approach where the LLM writes throwaway regex scripts every run. + +**Tech Stack:** web-tree-sitter (WASM), TypeScript, Vitest + +--- + +## File Structure + +``` +packages/core/src/plugins/ +├── extractors/ +│ ├── types.ts # LanguageExtractor interface + TreeSitterNode re-export +│ ├── base-extractor.ts # Shared utilities (traverse, getStringValue) +│ ├── typescript-extractor.ts # TS/JS (moved from tree-sitter-plugin.ts) +│ ├── python-extractor.ts +│ ├── go-extractor.ts +│ ├── rust-extractor.ts +│ ├── java-extractor.ts +│ ├── ruby-extractor.ts +│ ├── php-extractor.ts +│ ├── cpp-extractor.ts +│ ├── csharp-extractor.ts +│ └── index.ts # builtinExtractors array + re-exports +├── tree-sitter-plugin.ts # Refactored to use extractors +└── tree-sitter-plugin.test.ts # Existing tests (should still pass) + +packages/core/src/plugins/__tests__/ +└── extractors.test.ts # Tests for all new extractors + +skills/understand/ +├── extract-structure.mjs # Pre-built tree-sitter extraction script (NEW) +└── SKILL.md # Updated to reference extract-structure.mjs + +agents/ +└── file-analyzer.md # Phase 1 rewritten to execute pre-built script +``` + +--- + +### Task 1: Create LanguageExtractor interface and shared utilities + +**Files:** +- Create: `packages/core/src/plugins/extractors/types.ts` +- Create: `packages/core/src/plugins/extractors/base-extractor.ts` + +- [ ] **Step 1: Create the extractor interface** + +```typescript +// packages/core/src/plugins/extractors/types.ts +import type { StructuralAnalysis, CallGraphEntry } from "../../types.js"; + +// Re-export the tree-sitter Node type for use by extractors +export type TreeSitterNode = import("web-tree-sitter").Node; + +/** + * Language-specific extractor that maps a tree-sitter AST + * to the common StructuralAnalysis / CallGraphEntry types. + */ +export interface LanguageExtractor { + /** Language IDs this extractor handles (must match LanguageConfig.id) */ + languageIds: string[]; + + /** Extract functions, classes, imports, exports from the root AST node */ + extractStructure(rootNode: TreeSitterNode): StructuralAnalysis; + + /** Extract caller→callee relationships from the root AST node */ + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[]; +} +``` + +- [ ] **Step 2: Create base-extractor with shared utilities** + +Move `traverse()` and `getStringValue()` from `tree-sitter-plugin.ts` into a shared module: + +```typescript +// packages/core/src/plugins/extractors/base-extractor.ts +import type { TreeSitterNode } from "./types.js"; + +/** Recursively traverse an AST tree, calling the visitor for each node. */ +export function traverse( + node: TreeSitterNode, + visitor: (node: TreeSitterNode) => void, +): void { + visitor(node); + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) traverse(child, visitor); + } +} + +/** Extract the unquoted string value from a string-like node. */ +export function getStringValue(node: TreeSitterNode): string { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && child.type === "string_fragment") { + return child.text; + } + } + return node.text.replace(/^['"`]|['"`]$/g, ""); +} + +/** Find the first child matching a type. */ +export function findChild(node: TreeSitterNode, type: string): TreeSitterNode | null { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && child.type === type) return child; + } + return null; +} + +/** Find all children matching a type. */ +export function findChildren(node: TreeSitterNode, type: string): TreeSitterNode[] { + const result: TreeSitterNode[] = []; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && child.type === type) result.push(child); + } + return result; +} + +/** Check if a node has a child of the given type (used for export/visibility checks). */ +export function hasChildOfType(node: TreeSitterNode, type: string): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && child.type === type) return true; + } + return false; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/core/src/plugins/extractors/types.ts packages/core/src/plugins/extractors/base-extractor.ts +git commit -m "feat: add LanguageExtractor interface and shared base utilities" +``` + +--- + +### Task 2: Move TS/JS extraction logic into TypeScriptExtractor + +**Files:** +- Create: `packages/core/src/plugins/extractors/typescript-extractor.ts` +- Modify: `packages/core/src/plugins/tree-sitter-plugin.ts` + +This is a pure refactor. All existing tests must still pass with zero changes. + +- [ ] **Step 1: Create TypeScriptExtractor** + +Move all the TS/JS-specific extraction methods (`extractFunction`, `extractClass`, `extractVariableDeclarations`, `extractImport`, `processExportStatement`, `extractParams`, `extractReturnType`, `extractImportSpecifiers`, and the call graph walker) from `tree-sitter-plugin.ts` into `typescript-extractor.ts`, implementing the `LanguageExtractor` interface. + +The `languageIds` should be `["typescript", "javascript"]`. Do NOT include `"tsx"` — it is a synthetic key internal to `TreeSitterPlugin` for grammar selection, not a `LanguageConfig.id`. The tsx→typescript mapping is handled in `getExtractor()` below. + +- [ ] **Step 2: Refactor TreeSitterPlugin to use extractors** + +Replace the hardcoded extraction logic in `TreeSitterPlugin` with extractor dispatch: + +```typescript +// In TreeSitterPlugin +private extractors = new Map(); + +registerExtractor(extractor: LanguageExtractor): void { + for (const id of extractor.languageIds) { + this.extractors.set(id, extractor); + } +} + +private getExtractor(langKey: string): LanguageExtractor | null { + // tsx is a synthetic grammar key — extraction logic is identical to typescript + const key = langKey === "tsx" ? "typescript" : langKey; + return this.extractors.get(key) ?? null; +} +``` + +The `analyzeFile()` method becomes: + +```typescript +analyzeFile(filePath: string, content: string): StructuralAnalysis { + const parser = this.getParser(filePath); + if (!parser) return { functions: [], classes: [], imports: [], exports: [] }; + + const tree = parser.parse(content); + if (!tree) { parser.delete(); return { functions: [], classes: [], imports: [], exports: [] }; } + + const langKey = this.languageKeyFromPath(filePath); + const extractor = langKey ? this.getExtractor(langKey) : null; + + let result: StructuralAnalysis; + if (extractor) { + result = extractor.extractStructure(tree.rootNode); + } else { + result = { functions: [], classes: [], imports: [], exports: [] }; + } + + tree.delete(); + parser.delete(); + return result; +} +``` + +The `extractCallGraph()` method follows the same pattern — parser lifecycle must be managed identically: + +```typescript +extractCallGraph(filePath: string, content: string): CallGraphEntry[] { + const parser = this.getParser(filePath); + if (!parser) return []; + + const tree = parser.parse(content); + if (!tree) { parser.delete(); return []; } + + const langKey = this.languageKeyFromPath(filePath); + const extractor = langKey ? this.getExtractor(langKey) : null; + const result = extractor ? extractor.extractCallGraph(tree.rootNode) : []; + + tree.delete(); + parser.delete(); + return result; +} +``` + +The constructor should accept an optional `extractors` array and register them. If none provided, register the built-in `TypeScriptExtractor` for backward compatibility. + +- [ ] **Step 3: Run existing tests to verify zero behavior change** + +Run: `pnpm --filter @understand-anything/core test` +Expected: All 426 tests pass (identical to before) + +- [ ] **Step 4: Commit** + +```bash +git add packages/core/src/plugins/extractors/typescript-extractor.ts packages/core/src/plugins/tree-sitter-plugin.ts +git commit -m "refactor: move TS/JS extraction logic to TypeScriptExtractor, dispatch via LanguageExtractor interface" +``` + +--- + +### Task 2.5: Add extractCallGraph to PluginRegistry and update DEFAULT_PLUGIN_CONFIG + +**Files:** +- Modify: `packages/core/src/plugins/registry.ts` +- Modify: `packages/core/src/plugins/discovery.ts` + +**Context:** `PluginRegistry` currently only exposes `analyzeFile` and `resolveImports` — it has no `extractCallGraph`. The `extract-structure.mjs` script (Task 13) needs call graph data through the registry. Also, `DEFAULT_PLUGIN_CONFIG` hardcodes `["typescript", "javascript"]` which needs to reflect all supported languages. + +- [ ] **Step 1: Add extractCallGraph to PluginRegistry** + +```typescript +// In PluginRegistry (registry.ts) +extractCallGraph(filePath: string, content: string): CallGraphEntry[] | null { + const plugin = this.getPluginForFile(filePath); + if (!plugin?.extractCallGraph) return null; + return plugin.extractCallGraph(filePath, content); +} +``` + +- [ ] **Step 2: Update DEFAULT_PLUGIN_CONFIG to derive languages dynamically** + +In `discovery.ts`, replace the hardcoded `["typescript", "javascript"]` with a dynamic derivation from `builtinLanguageConfigs`: + +```typescript +import { builtinLanguageConfigs } from "../languages/configs/index.js"; + +export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { + plugins: [ + { + name: "tree-sitter", + enabled: true, + languages: builtinLanguageConfigs + .filter((c) => c.treeSitter) + .map((c) => c.id), + }, + ], +}; +``` + +- [ ] **Step 3: Run tests, commit** + +```bash +pnpm --filter @understand-anything/core test +git add packages/core/src/plugins/registry.ts packages/core/src/plugins/discovery.ts +git commit -m "feat: add extractCallGraph to PluginRegistry, derive DEFAULT_PLUGIN_CONFIG from configs" +``` + +--- + +### Task 3: Add npm dependencies and treeSitter configs for all 10 languages + +**Files:** +- Modify: `packages/core/package.json` (add 8 deps: python, go, rust, java, ruby, php, cpp, c-sharp) +- Modify: 10 config files in `packages/core/src/languages/configs/` + +- [ ] **Step 1: Add tree-sitter grammar dependencies to package.json** + +Add to `dependencies`: + +```json +"tree-sitter-c-sharp": "^0.23.1", +"tree-sitter-cpp": "^0.23.4", +"tree-sitter-go": "^0.25.0", +"tree-sitter-java": "^0.23.5", +"tree-sitter-php": "^0.23.11", +"tree-sitter-python": "^0.25.0", +"tree-sitter-ruby": "^0.23.1", +"tree-sitter-rust": "^0.24.0" +``` + +Then run `pnpm install`. + +- [ ] **Step 2: Add treeSitter field to all 10 language configs** + +Each config gets a `treeSitter` block. Examples: + +```typescript +// python.ts +treeSitter: { wasmPackage: "tree-sitter-python", wasmFile: "tree-sitter-python.wasm" }, + +// go.ts +treeSitter: { wasmPackage: "tree-sitter-go", wasmFile: "tree-sitter-go.wasm" }, + +// rust.ts +treeSitter: { wasmPackage: "tree-sitter-rust", wasmFile: "tree-sitter-rust.wasm" }, + +// java.ts +treeSitter: { wasmPackage: "tree-sitter-java", wasmFile: "tree-sitter-java.wasm" }, + +// ruby.ts +treeSitter: { wasmPackage: "tree-sitter-ruby", wasmFile: "tree-sitter-ruby.wasm" }, + +// php.ts +treeSitter: { wasmPackage: "tree-sitter-php", wasmFile: "tree-sitter-php.wasm" }, + +// cpp.ts +treeSitter: { wasmPackage: "tree-sitter-cpp", wasmFile: "tree-sitter-cpp.wasm" }, + +// csharp.ts +treeSitter: { wasmPackage: "tree-sitter-c-sharp", wasmFile: "tree-sitter-c_sharp.wasm" }, +``` + +Note: Swift and Kotlin configs are NOT changed (no WASM packages available). + +- [ ] **Step 3: Run pnpm install and verify WASM files resolve** + +```bash +pnpm install +node -e "const r=require('module').createRequire(import.meta.url??__filename); console.log(r.resolve('tree-sitter-python/tree-sitter-python.wasm'))" +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/core/package.json pnpm-lock.yaml packages/core/src/languages/configs/ +git commit -m "feat: add tree-sitter grammar deps and treeSitter configs for 10 languages" +``` + +--- + +### Task 4: Create Python extractor + +**Files:** +- Create: `packages/core/src/plugins/extractors/python-extractor.ts` + +- [ ] **Step 1: Write the Python extractor** + +Key Python tree-sitter node types: +- Functions: `function_definition` (name, parameters, return_type) +- Classes: `class_definition` (name, body → methods + assignments as properties) +- Imports: `import_statement`, `import_from_statement` +- Decorated: `decorated_definition` wrapping function_definition or class_definition +- Calls: `call` (function field) +- No formal exports (all top-level names are "exported") + +```typescript +languageIds: ["python"] +``` + +- [ ] **Step 2: Write tests for Python extractor** + +Test with representative Python code: + +```python +import os +from pathlib import Path +from typing import Optional + +class DataProcessor: + name: str + + def __init__(self, name: str): + self.name = name + + def process(self, data: list) -> dict: + return transform(data) + +def helper(x: int) -> str: + return str(x) + +@decorator +def decorated_func(): + pass +``` + +Verify: 2 functions (helper, decorated_func), 1 class (DataProcessor with methods __init__/process and property name), 3 imports, call graph (process→transform). + +- [ ] **Step 3: Run tests** + +Run: `pnpm --filter @understand-anything/core test` + +- [ ] **Step 4: Commit** + +--- + +### Task 5: Create Go extractor + +**Files:** +- Create: `packages/core/src/plugins/extractors/go-extractor.ts` + +- [ ] **Step 1: Write the Go extractor** + +Key Go tree-sitter node types: +- Functions: `function_declaration` (name, parameter_list, result) +- Methods: `method_declaration` (receiver, name, parameter_list, result) +- Structs: `type_declaration` → `type_spec` → `struct_type` +- Interfaces: `type_declaration` → `type_spec` → `interface_type` +- Imports: `import_declaration` → `import_spec_list` → `import_spec` +- Exports: capitalized first letter of name +- Calls: `call_expression` (function field) + +```typescript +languageIds: ["go"] +``` + +- [ ] **Step 2: Write tests** + +Test with: +```go +package main + +import ( + "fmt" + "os" +) + +type Server struct { + Host string + Port int +} + +func (s *Server) Start() error { + fmt.Println("starting") + return nil +} + +func NewServer(host string, port int) *Server { + return &Server{Host: host, Port: port} +} +``` + +Verify: 2 functions (Start, NewServer), 1 class/struct (Server with method Start, properties Host/Port), 2 imports, exports (Server, Start, NewServer — all capitalized), call graph (Start→fmt.Println). + +- [ ] **Step 3: Run tests and commit** + +--- + +### Task 6: Create Rust extractor + +**Files:** +- Create: `packages/core/src/plugins/extractors/rust-extractor.ts` + +- [ ] **Step 1: Write the Rust extractor** + +Key Rust tree-sitter node types: +- Functions: `function_item` (name, parameters, return_type via `->`) +- Structs: `struct_item` (name, field_declaration_list) +- Enums: `enum_item` +- Impl blocks: `impl_item` (type, body containing function_items) +- Traits: `trait_item` +- Imports: `use_declaration` (scoped_identifier, use_list, use_wildcard) +- Exports: `visibility_modifier` containing `pub` +- Calls: `call_expression` (function field) + +```typescript +languageIds: ["rust"] +``` + +- [ ] **Step 2: Write tests** + +Test with: +```rust +use std::collections::HashMap; +use std::io::{self, Read}; + +pub struct Config { + name: String, + port: u16, +} + +impl Config { + pub fn new(name: String, port: u16) -> Self { + Config { name, port } + } + + fn validate(&self) -> bool { + check_port(self.port) + } +} + +pub fn check_port(port: u16) -> bool { + port > 0 +} +``` + +Verify: 3 functions (new, validate, check_port), 1 class/struct (Config with methods new/validate, properties name/port), 2 imports, exports (Config, new, check_port — those with `pub`), call graph (validate→check_port). + +- [ ] **Step 3: Run tests and commit** + +--- + +### Task 7: Create Java extractor + +**Files:** +- Create: `packages/core/src/plugins/extractors/java-extractor.ts` + +- [ ] **Step 1: Write the Java extractor** + +Key Java tree-sitter node types: +- Methods: `method_declaration` (name, formal_parameters, type/dimensions) +- Constructors: `constructor_declaration` (name, formal_parameters) +- Classes: `class_declaration` (name, class_body) +- Interfaces: `interface_declaration` +- Fields: `field_declaration` (declarator → variable_declarator → identifier) +- Imports: `import_declaration` (scoped_identifier) +- Exports: `public` modifier (modifiers node) +- Calls: `method_invocation` (name, object, arguments) + +```typescript +languageIds: ["java"] +``` + +- [ ] **Step 2: Write tests with representative Java code, run, commit** + +--- + +### Task 8: Create Ruby extractor + +**Files:** +- Create: `packages/core/src/plugins/extractors/ruby-extractor.ts` + +- [ ] **Step 1: Write the Ruby extractor** + +Key Ruby tree-sitter node types: +- Methods: `method` (name, parameters) +- Classes: `class` (name, body containing methods) +- Modules: `module` (name) +- Imports: `call` where method is `require` or `require_relative` (Ruby uses method calls for imports) +- Calls: `call` (method, receiver, arguments) +- No formal export syntax + +```typescript +languageIds: ["ruby"] +``` + +- [ ] **Step 2: Write tests, run, commit** + +--- + +### Task 9: Create PHP extractor + +**Files:** +- Create: `packages/core/src/plugins/extractors/php-extractor.ts` + +- [ ] **Step 1: Write the PHP extractor** + +Key PHP tree-sitter node types: +- Functions: `function_definition` (name, formal_parameters, return_type) +- Methods: `method_declaration` (name, formal_parameters, return_type) +- Classes: `class_declaration` (name, declaration_list) +- Imports: `namespace_use_declaration` (namespace_use_clause) +- Calls: `function_call_expression` / `member_call_expression` +- Note: PHP tree wraps everything in a `program` → `php_tag` + statements + +```typescript +languageIds: ["php"] +``` + +- [ ] **Step 2: Write tests, run, commit** + +--- + +### Task 10: Create C/C++ extractor + +**Files:** +- Create: `packages/core/src/plugins/extractors/cpp-extractor.ts` + +- [ ] **Step 1: Write the C/C++ extractor** + +Key C/C++ tree-sitter node types: +- Functions: `function_definition` (declarator → function_declarator → identifier + parameter_list) +- Classes: `class_specifier` (name, body → field_declaration_list) +- Structs: `struct_specifier` (name, body) +- Includes: `preproc_include` (path → string_literal or system_lib_string) +- Namespaces: `namespace_definition` +- Calls: `call_expression` (function, arguments) + +Note: C/C++ function signatures are nested (the name is inside a `function_declarator` inside the `declarator` field). + +The `cppConfig` has `id: "cpp"` and `extensions: [".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", ".hxx"]`. Pure C files (`.c`, `.h`) are parsed with the C++ grammar, which works but won't produce C++-specific node types like `class_specifier`. The extractor must handle their absence gracefully (return empty arrays for classes when parsing pure C). + +```typescript +languageIds: ["cpp"] +``` + +- [ ] **Step 2: Write tests for both C++ and pure C code, run, commit** + +--- + +### Task 11: Create C# extractor + +**Files:** +- Create: `packages/core/src/plugins/extractors/csharp-extractor.ts` + +- [ ] **Step 1: Write the C# extractor** + +Key C# tree-sitter node types: +- Methods: `method_declaration` (name, parameter_list, return type) +- Constructors: `constructor_declaration` +- Classes: `class_declaration` (name, declaration_list) +- Interfaces: `interface_declaration` +- Properties: `property_declaration` (name, type) +- Imports: `using_directive` (qualified_name) +- Calls: `invocation_expression` (identifier/member_access, argument_list) + +```typescript +languageIds: ["csharp"] +``` + +- [ ] **Step 2: Write tests, run, commit** + +--- + +### Task 12: Create extractor index and wire into TreeSitterPlugin + +**Files:** +- Create: `packages/core/src/plugins/extractors/index.ts` +- Modify: `packages/core/src/plugins/tree-sitter-plugin.ts` (import builtinExtractors) + +- [ ] **Step 1: Create index.ts exporting all extractors** + +```typescript +// packages/core/src/plugins/extractors/index.ts +export type { LanguageExtractor, TreeSitterNode } from "./types.js"; +export { traverse, getStringValue, findChild, findChildren, hasChildOfType } from "./base-extractor.js"; +export { TypeScriptExtractor } from "./typescript-extractor.js"; +export { PythonExtractor } from "./python-extractor.js"; +export { GoExtractor } from "./go-extractor.js"; +export { RustExtractor } from "./rust-extractor.js"; +export { JavaExtractor } from "./java-extractor.js"; +export { RubyExtractor } from "./ruby-extractor.js"; +export { PhpExtractor } from "./php-extractor.js"; +export { CppExtractor } from "./cpp-extractor.js"; +export { CSharpExtractor } from "./csharp-extractor.js"; + +import type { LanguageExtractor } from "./types.js"; +import { TypeScriptExtractor } from "./typescript-extractor.js"; +import { PythonExtractor } from "./python-extractor.js"; +import { GoExtractor } from "./go-extractor.js"; +import { RustExtractor } from "./rust-extractor.js"; +import { JavaExtractor } from "./java-extractor.js"; +import { RubyExtractor } from "./ruby-extractor.js"; +import { PhpExtractor } from "./php-extractor.js"; +import { CppExtractor } from "./cpp-extractor.js"; +import { CSharpExtractor } from "./csharp-extractor.js"; + +export const builtinExtractors: LanguageExtractor[] = [ + new TypeScriptExtractor(), + new PythonExtractor(), + new GoExtractor(), + new RustExtractor(), + new JavaExtractor(), + new RubyExtractor(), + new PhpExtractor(), + new CppExtractor(), + new CSharpExtractor(), +]; +``` + +- [ ] **Step 2: Wire builtinExtractors into TreeSitterPlugin constructor** + +When no extractors are provided, default to `builtinExtractors`. + +- [ ] **Step 3: Run full test suite** + +Run: `pnpm --filter @understand-anything/core test` +Expected: All tests pass (existing + new extractor tests) + +- [ ] **Step 4: Commit** + +--- + +### Task 13: Create bundled extract-structure.mjs script + +**Files:** +- Create: `skills/understand/extract-structure.mjs` + +**Context:** Currently the file-analyzer agent (Phase 1) instructs the LLM to write a throwaway regex-based Node.js/Python script every run. This is slow, non-deterministic, and ignores the tree-sitter infrastructure we just built. This task replaces that with a pre-built script that uses `PluginRegistry` (which routes to `TreeSitterPlugin` for code files and to the regex parsers for non-code files). + +- [ ] **Step 1: Create extract-structure.mjs** + +The script: +1. Accepts input JSON path (arg 1) and output JSON path (arg 2) +2. Input format matches what file-analyzer.md already specifies: `{ projectRoot, batchFiles: [{path, language, sizeLines, fileCategory}], batchImportData }` +3. Resolves `@understand-anything/core` from the plugin's own `node_modules` using `createRequire` relative to the script's own location (two directories up to plugin root) +4. Creates a `PluginRegistry` with `TreeSitterPlugin` (all builtin language configs) + all non-code parsers registered +5. For each file: reads content, calls `registry.analyzeFile()`, formats output to match the existing script output schema (functions, classes, exports, sections, definitions, services, etc.) +6. For code files with tree-sitter support: also extracts call graph via `plugin.extractCallGraph()` +7. For files where no plugin exists (Swift, Kotlin, unknown languages): outputs `{ path, language, fileCategory, totalLines, nonEmptyLines, metrics }` with empty structural data — the LLM agent handles these in Phase 2 +8. Writes output JSON matching the existing `scriptCompleted/filesAnalyzed/filesSkipped/results` schema + +Key resolution logic (with fallback for different install layouts): +```javascript +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pluginRoot = resolve(__dirname, '../..'); +const require = createRequire(resolve(pluginRoot, 'package.json')); + +let core; +try { + core = await import(require.resolve('@understand-anything/core')); +} catch { + // Fallback: direct path for installed plugin cache where pnpm symlinks may differ + core = await import(resolve(pluginRoot, 'packages/core/dist/index.js')); +} +``` + +- [ ] **Step 2: Test the script locally** + +Create a small test input JSON with a TS file, a Python file, and a YAML file. Run: +```bash +node skills/understand/extract-structure.mjs test-input.json test-output.json +``` +Verify the output contains structural data for all three. + +- [ ] **Step 3: Commit** + +```bash +git add skills/understand/extract-structure.mjs +git commit -m "feat: add bundled tree-sitter extraction script for file-analyzer agent" +``` + +--- + +### Task 14: Rewrite file-analyzer.md Phase 1 to use bundled script + +**Files:** +- Modify: `agents/file-analyzer.md` + +**Context:** Phase 1 currently has ~150 lines instructing the agent to write a custom extraction script from scratch. Replace this with a short section that tells the agent to execute the pre-built `extract-structure.mjs` script. + +- [ ] **Step 1: Replace Phase 1 in file-analyzer.md** + +Delete the entire current Phase 1 (~150 lines of regex script generation instructions). Replace with: + +1. Tell the agent to prepare the input JSON file (same format as before): + ```bash + cat > $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-.json << 'ENDJSON' + { + "projectRoot": "", + "batchFiles": [], + "batchImportData": + } + ENDJSON + ``` + +2. Execute the bundled script: + ```bash + node /extract-structure.mjs \ + $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-.json \ + $PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-.json + ``` + +3. If the script exits non-zero, read stderr, diagnose and report the error. Do NOT fall back to writing a manual script — the bundled script is the sole extraction path. + +4. Keep the existing output format — Phase 2 (semantic analysis) is unchanged. + +- [ ] **Step 2: Update SKILL.md to pass SKILL_DIR to file-analyzer dispatch** + +In SKILL.md Phase 2, the file-analyzer dispatch prompt must include the skill directory path so the agent can locate `extract-structure.mjs`. + +Add to the dispatch parameters: +``` +> Skill directory (for bundled scripts): `` +``` + +This follows the established pattern — SKILL.md already passes `` for `merge-batch-graphs.py` (line 213) and `merge-subdomain-graphs.py` (line 44) using the same mechanism. + +- [ ] **Step 3: Verify the file-analyzer output format is unchanged** + +Phase 2 of file-analyzer.md should NOT need changes — it reads the same JSON structure from the script results. Verify the output schema from `extract-structure.mjs` matches what Phase 2 expects. + +- [ ] **Step 4: Commit** + +```bash +git add agents/file-analyzer.md skills/understand/SKILL.md +git commit -m "feat: file-analyzer uses bundled tree-sitter script instead of LLM-generated regex" +``` + +--- + +### Task 15: Final integration verification and cleanup + +- [ ] **Step 1: Add exports to packages/core/src/index.ts** + +This is required — `extract-structure.mjs` and external consumers need these exports: + +```typescript +export type { LanguageExtractor } from "./plugins/extractors/types.js"; +export { builtinExtractors } from "./plugins/extractors/index.js"; +``` + +- [ ] **Step 2: Build the full package** + +```bash +pnpm --filter @understand-anything/core build +``` + +- [ ] **Step 3: Run full test suite one final time** + +```bash +pnpm --filter @understand-anything/core test +``` + +- [ ] **Step 4: Final commit** + +```bash +git commit -m "feat: complete language extractor architecture — 10 languages with tree-sitter support" +``` + +--- + +## Implementation Notes + +**Test file convention:** Each language extractor gets its own test file at `packages/core/src/plugins/extractors/__tests__/-extractor.test.ts`. This follows the existing pattern where `tree-sitter-plugin.test.ts` is co-located. + +**Lazy grammar loading (future optimization):** The current `TreeSitterPlugin.init()` loads all grammar WASMs upfront via `Promise.all`. With 10 grammars (~12MB total WASM), this may cause noticeable init delay. A future improvement: load TS/JS eagerly (most common), defer others to first use. Not required for this PR — measure first. + +**Fingerprint side effect:** `buildFingerprintStore` in `fingerprint.ts` uses `PluginRegistry.analyzeFile` internally. Once the new extractors are wired up, fingerprinting for Python/Go/Rust/etc. will automatically produce structural fingerprints instead of content-hash-only. No code changes needed — it happens for free. + +**PHP grammar note:** `tree-sitter-php` ships both `tree-sitter-php.wasm` (full PHP + embedded HTML/CSS/JS) and `tree-sitter-php_only.wasm` (PHP only). We use `tree-sitter-php.wasm`. The PHP extractor should be robust to non-PHP AST nodes that appear when parsing files with embedded HTML templates. From 4f954907ec86d071aa47906aedff60bf0dbf2a6c Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 18:12:07 +0800 Subject: [PATCH 03/18] feat: add LanguageExtractor interface and shared base utilities Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/plugins/extractors/base-extractor.ts | 52 +++++++++++++++++++ .../core/src/plugins/extractors/types.ts | 19 +++++++ 2 files changed, 71 insertions(+) create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/base-extractor.ts create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/types.ts diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/base-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/base-extractor.ts new file mode 100644 index 0000000..814462f --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/base-extractor.ts @@ -0,0 +1,52 @@ +import type { TreeSitterNode } from "./types.js"; + +/** Recursively traverse an AST tree, calling the visitor for each node. */ +export function traverse( + node: TreeSitterNode, + visitor: (node: TreeSitterNode) => void, +): void { + visitor(node); + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) traverse(child, visitor); + } +} + +/** Extract the unquoted string value from a string-like node. */ +export function getStringValue(node: TreeSitterNode): string { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && child.type === "string_fragment") { + return child.text; + } + } + return node.text.replace(/^['"`]|['"`]$/g, ""); +} + +/** Find the first child matching a type. */ +export function findChild(node: TreeSitterNode, type: string): TreeSitterNode | null { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && child.type === type) return child; + } + return null; +} + +/** Find all children matching a type. */ +export function findChildren(node: TreeSitterNode, type: string): TreeSitterNode[] { + const result: TreeSitterNode[] = []; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && child.type === type) result.push(child); + } + return result; +} + +/** Check if a node has a child of the given type (used for export/visibility checks). */ +export function hasChildOfType(node: TreeSitterNode, type: string): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && child.type === type) return true; + } + return false; +} diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/types.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/types.ts new file mode 100644 index 0000000..2370ebe --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/types.ts @@ -0,0 +1,19 @@ +import type { StructuralAnalysis, CallGraphEntry } from "../../types.js"; + +// Re-export the tree-sitter Node type for use by extractors +export type TreeSitterNode = import("web-tree-sitter").Node; + +/** + * Language-specific extractor that maps a tree-sitter AST + * to the common StructuralAnalysis / CallGraphEntry types. + */ +export interface LanguageExtractor { + /** Language IDs this extractor handles (must match LanguageConfig.id) */ + languageIds: string[]; + + /** Extract functions, classes, imports, exports from the root AST node */ + extractStructure(rootNode: TreeSitterNode): StructuralAnalysis; + + /** Extract caller→callee relationships from the root AST node */ + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[]; +} From ab011ff856c6e5233c745d226052dbfec270b478 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 18:17:09 +0800 Subject: [PATCH 04/18] refactor: move TS/JS extraction logic to TypeScriptExtractor, dispatch via LanguageExtractor interface Extract all TypeScript/JavaScript-specific AST extraction functions (extractParams, extractReturnType, extractImportSpecifiers, processTopLevelNode, extractFunction, extractClass, extractVariableDeclarations, extractImport, processExportStatement, and call graph walking) from TreeSitterPlugin into the new TypeScriptExtractor class. TreeSitterPlugin now dispatches to registered LanguageExtractor instances, defaulting to TypeScriptExtractor for backward compatibility. All 426 existing tests pass unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../extractors/typescript-extractor.ts | 483 ++++++++++++++++ .../core/src/plugins/tree-sitter-plugin.ts | 533 ++---------------- 2 files changed, 525 insertions(+), 491 deletions(-) create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/typescript-extractor.ts diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/typescript-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/typescript-extractor.ts new file mode 100644 index 0000000..c2bc022 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/typescript-extractor.ts @@ -0,0 +1,483 @@ +import type { StructuralAnalysis, CallGraphEntry } from "../../types.js"; +import type { LanguageExtractor, TreeSitterNode } from "./types.js"; +import { getStringValue } from "./base-extractor.js"; + +/** + * Extract parameter names from a formal_parameters node. + */ +function extractParams(paramsNode: TreeSitterNode | null): string[] { + if (!paramsNode) return []; + const params: string[] = []; + for (let i = 0; i < paramsNode.childCount; i++) { + const child = paramsNode.child(i); + if (!child) continue; + if ( + child.type === "required_parameter" || + child.type === "optional_parameter" + ) { + const ident = + child.childForFieldName("pattern") ?? + child.childForFieldName("name"); + if (ident) { + params.push(ident.text); + } else { + // Fallback: first identifier child + for (let j = 0; j < child.childCount; j++) { + const c = child.child(j); + if (c && c.type === "identifier") { + params.push(c.text); + break; + } + } + } + } else if (child.type === "identifier") { + // JavaScript parameters (no type annotation) + params.push(child.text); + } else if ( + child.type === "rest_pattern" || + child.type === "rest_element" + ) { + const ident = child.children.find( + (c) => c.type === "identifier", + ); + if (ident) params.push("..." + ident.text); + } + } + return params; +} + +/** + * Extract return type annotation from a function-like node. + */ +function extractReturnType( + node: TreeSitterNode, +): string | undefined { + const typeAnnotation = node.childForFieldName("return_type"); + if (typeAnnotation && typeAnnotation.type === "type_annotation") { + const text = typeAnnotation.text; + return text.startsWith(":") ? text.slice(1).trim() : text; + } + return undefined; +} + +/** + * Extract import specifiers from an import_clause node. + */ +function extractImportSpecifiers( + importClause: TreeSitterNode, +): string[] { + const specifiers: string[] = []; + + for (let i = 0; i < importClause.childCount; i++) { + const child = importClause.child(i); + if (!child) continue; + + if (child.type === "named_imports") { + for (let j = 0; j < child.childCount; j++) { + const spec = child.child(j); + if (spec && spec.type === "import_specifier") { + const alias = spec.childForFieldName("alias"); + const name = spec.childForFieldName("name"); + specifiers.push( + alias ? alias.text : name ? name.text : spec.text, + ); + } + } + } else if (child.type === "namespace_import") { + const ident = child.children.find( + (c) => c.type === "identifier", + ); + if (ident) specifiers.push("* as " + ident.text); + } else if (child.type === "identifier") { + // default import: import foo from '...' + specifiers.push(child.text); + } + } + + return specifiers; +} + +/** + * TypeScript/JavaScript extractor. + * + * Handles structural analysis and call-graph extraction for + * TypeScript and JavaScript ASTs produced by tree-sitter. + */ +export class TypeScriptExtractor implements LanguageExtractor { + readonly languageIds = ["typescript", "javascript"]; + + extractStructure(rootNode: TreeSitterNode): StructuralAnalysis { + const functions: StructuralAnalysis["functions"] = []; + const classes: StructuralAnalysis["classes"] = []; + const imports: StructuralAnalysis["imports"] = []; + const exports: StructuralAnalysis["exports"] = []; + const exportedNames = new Set(); + + for (let i = 0; i < rootNode.childCount; i++) { + const node = rootNode.child(i); + if (!node) continue; + this.processTopLevelNode( + node, + functions, + classes, + imports, + exports, + exportedNames, + ); + } + + return { functions, classes, imports, exports }; + } + + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] { + const entries: CallGraphEntry[] = []; + const functionStack: string[] = []; + + const walkForCalls = (node: TreeSitterNode) => { + const isFunctionLike = + node.type === "function_declaration" || + node.type === "method_definition" || + node.type === "arrow_function" || + node.type === "function_expression"; + + let pushedName = false; + if (isFunctionLike) { + let name: string | undefined; + if (node.type === "function_declaration") { + name = ( + node.childForFieldName("name") ?? + node.children.find((c) => c.type === "identifier") + )?.text; + } else if (node.type === "method_definition") { + name = node.children.find( + (c) => c.type === "property_identifier", + )?.text; + } else if ( + node.type === "arrow_function" || + node.type === "function_expression" + ) { + const parent = node.parent; + if (parent && parent.type === "variable_declarator") { + name = parent.childForFieldName("name")?.text; + } + } + if (name) { + functionStack.push(name); + pushedName = true; + } + } + + if (node.type === "call_expression") { + const callee = node.childForFieldName("function"); + if (callee && functionStack.length > 0) { + entries.push({ + caller: functionStack[functionStack.length - 1], + callee: callee.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) walkForCalls(child); + } + + if (pushedName) { + functionStack.pop(); + } + }; + + walkForCalls(rootNode); + + return entries; + } + + // ---- Private extraction helpers ---- + + private processTopLevelNode( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + classes: StructuralAnalysis["classes"], + imports: StructuralAnalysis["imports"], + exports: StructuralAnalysis["exports"], + exportedNames: Set, + ): void { + switch (node.type) { + case "function_declaration": + this.extractFunction(node, functions); + break; + + case "class_declaration": + this.extractClass(node, classes); + break; + + case "lexical_declaration": + case "variable_declaration": + this.extractVariableDeclarations(node, functions); + break; + + case "import_statement": + this.extractImport(node, imports); + break; + + case "export_statement": + this.processExportStatement( + node, + functions, + classes, + imports, + exports, + exportedNames, + ); + break; + } + } + + private extractFunction( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + ): void { + const nameNode = + node.childForFieldName("name") ?? + node.children.find((c) => c.type === "identifier"); + if (!nameNode) return; + + const params = extractParams( + node.childForFieldName("parameters") ?? + node.children.find( + (c) => c.type === "formal_parameters", + ) ?? + null, + ); + const returnType = extractReturnType(node); + + functions.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + params, + returnType, + }); + } + + private extractClass( + node: TreeSitterNode, + classes: StructuralAnalysis["classes"], + ): void { + const nameNode = node.children.find( + (c) => + c.type === "type_identifier" || c.type === "identifier", + ); + if (!nameNode) return; + + const methods: string[] = []; + const properties: string[] = []; + + const classBody = node.children.find( + (c) => c.type === "class_body", + ); + if (classBody) { + for (let j = 0; j < classBody.childCount; j++) { + const member = classBody.child(j); + if (!member) continue; + + if (member.type === "method_definition") { + const methodName = member.children.find( + (c) => c.type === "property_identifier", + ); + if (methodName) methods.push(methodName.text); + } else if ( + member.type === "public_field_definition" || + member.type === "property_definition" + ) { + const propName = member.children.find( + (c) => c.type === "property_identifier", + ); + if (propName) properties.push(propName.text); + } + } + } + + classes.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + methods, + properties, + }); + } + + private extractVariableDeclarations( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + ): void { + for (let j = 0; j < node.childCount; j++) { + const child = node.child(j); + if (!child || child.type !== "variable_declarator") continue; + + const nameNode = child.childForFieldName("name"); + const valueNode = child.childForFieldName("value"); + + if ( + nameNode && + valueNode && + (valueNode.type === "arrow_function" || + valueNode.type === "function_expression" || + valueNode.type === "function") + ) { + const params = extractParams( + valueNode.childForFieldName("parameters") ?? + valueNode.children.find( + (c) => c.type === "formal_parameters", + ) ?? + null, + ); + const returnType = extractReturnType(valueNode); + + functions.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + params, + returnType, + }); + } + } + } + + private extractImport( + node: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + const sourceNode = node.children.find( + (c) => c.type === "string", + ); + if (!sourceNode) return; + + const source = getStringValue(sourceNode); + const specifiers: string[] = []; + + const importClause = node.children.find( + (c) => c.type === "import_clause", + ); + if (importClause) { + specifiers.push(...extractImportSpecifiers(importClause)); + } + + imports.push({ + source, + specifiers, + lineNumber: node.startPosition.row + 1, + }); + } + + private processExportStatement( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + classes: StructuralAnalysis["classes"], + _imports: StructuralAnalysis["imports"], + exports: StructuralAnalysis["exports"], + exportedNames: Set, + ): void { + for (let j = 0; j < node.childCount; j++) { + const child = node.child(j); + if (!child) continue; + + switch (child.type) { + case "function_declaration": { + this.extractFunction(child, functions); + const nameNode = + child.childForFieldName("name") ?? + child.children.find((c) => c.type === "identifier"); + if (nameNode && !exportedNames.has(nameNode.text)) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + exportedNames.add(nameNode.text); + } + break; + } + + case "class_declaration": { + this.extractClass(child, classes); + const nameNode = child.children.find( + (c) => + c.type === "type_identifier" || + c.type === "identifier", + ); + if (nameNode && !exportedNames.has(nameNode.text)) { + const isDefault = node.children.some( + (c) => c.type === "default", + ); + const exportName = isDefault + ? "default" + : nameNode.text; + exports.push({ + name: exportName, + lineNumber: node.startPosition.row + 1, + }); + exportedNames.add(exportName); + } + break; + } + + case "lexical_declaration": + case "variable_declaration": { + this.extractVariableDeclarations(child, functions); + for (let k = 0; k < child.childCount; k++) { + const declarator = child.child(k); + if ( + declarator && + declarator.type === "variable_declarator" + ) { + const nameNode = + declarator.childForFieldName("name"); + if ( + nameNode && + !exportedNames.has(nameNode.text) + ) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + exportedNames.add(nameNode.text); + } + } + } + break; + } + + case "export_clause": { + for (let k = 0; k < child.childCount; k++) { + const spec = child.child(k); + if (spec && spec.type === "export_specifier") { + const alias = spec.childForFieldName("alias"); + const name = spec.childForFieldName("name"); + const exportName = alias + ? alias.text + : name + ? name.text + : spec.text; + if (!exportedNames.has(exportName)) { + exports.push({ + name: exportName, + lineNumber: node.startPosition.row + 1, + }); + exportedNames.add(exportName); + } + } + } + break; + } + } + } + } +} 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 ff0dbda..96dd0ab 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 @@ -7,6 +7,8 @@ import type { CallGraphEntry, } from "../types.js"; import type { LanguageConfig } from "../languages/types.js"; +import type { LanguageExtractor } from "./extractors/types.js"; +import { TypeScriptExtractor } from "./extractors/typescript-extractor.js"; // web-tree-sitter uses CJS internally; we need createRequire for .wasm resolution const require = createRequire(import.meta.url); @@ -15,130 +17,6 @@ type TreeSitterParser = import("web-tree-sitter").Parser; type TreeSitterLanguage = import("web-tree-sitter").Language; type TreeSitterNode = import("web-tree-sitter").Node; -/** - * Recursively traverse an AST tree, calling the visitor for each node. - */ -function traverse( - node: TreeSitterNode, - visitor: (node: TreeSitterNode) => void, -): void { - visitor(node); - for (let i = 0; i < node.childCount; i++) { - const child = node.child(i); - if (child) traverse(child, visitor); - } -} - -/** - * Extract the string fragment (unquoted value) from a string node. - */ -function getStringValue(node: TreeSitterNode): string { - for (let i = 0; i < node.childCount; i++) { - const child = node.child(i); - if (child && child.type === "string_fragment") { - return child.text; - } - } - // Fallback: strip quotes - const text = node.text; - return text.replace(/^['"`]|['"`]$/g, ""); -} - -/** - * Extract parameter names from a formal_parameters node. - */ -function extractParams(paramsNode: TreeSitterNode | null): string[] { - if (!paramsNode) return []; - const params: string[] = []; - for (let i = 0; i < paramsNode.childCount; i++) { - const child = paramsNode.child(i); - if (!child) continue; - if ( - child.type === "required_parameter" || - child.type === "optional_parameter" - ) { - const ident = - child.childForFieldName("pattern") ?? - child.childForFieldName("name"); - if (ident) { - params.push(ident.text); - } else { - // Fallback: first identifier child - for (let j = 0; j < child.childCount; j++) { - const c = child.child(j); - if (c && c.type === "identifier") { - params.push(c.text); - break; - } - } - } - } else if (child.type === "identifier") { - // JavaScript parameters (no type annotation) - params.push(child.text); - } else if ( - child.type === "rest_pattern" || - child.type === "rest_element" - ) { - const ident = child.children.find( - (c) => c.type === "identifier", - ); - if (ident) params.push("..." + ident.text); - } - } - return params; -} - -/** - * Extract return type annotation from a function-like node. - */ -function extractReturnType( - node: TreeSitterNode, -): string | undefined { - const typeAnnotation = node.childForFieldName("return_type"); - if (typeAnnotation && typeAnnotation.type === "type_annotation") { - const text = typeAnnotation.text; - return text.startsWith(":") ? text.slice(1).trim() : text; - } - return undefined; -} - -/** - * Extract import specifiers from an import_clause node. - */ -function extractImportSpecifiers( - importClause: TreeSitterNode, -): string[] { - const specifiers: string[] = []; - - for (let i = 0; i < importClause.childCount; i++) { - const child = importClause.child(i); - if (!child) continue; - - if (child.type === "named_imports") { - for (let j = 0; j < child.childCount; j++) { - const spec = child.child(j); - if (spec && spec.type === "import_specifier") { - const alias = spec.childForFieldName("alias"); - const name = spec.childForFieldName("name"); - specifiers.push( - alias ? alias.text : name ? name.text : spec.text, - ); - } - } - } else if (child.type === "namespace_import") { - const ident = child.children.find( - (c) => c.type === "identifier", - ); - if (ident) specifiers.push("* as " + ident.text); - } else if (child.type === "identifier") { - // default import: import foo from '...' - specifiers.push(child.text); - } - } - - return specifiers; -} - /** * Config-driven tree-sitter plugin. * @@ -164,12 +42,18 @@ export class TreeSitterPlugin implements AnalyzerPlugin { private _extensionToLang = new Map(); private _initialized = false; + // Language-specific extractors (keyed by language id) + private extractors = new Map(); + /** * Create a TreeSitterPlugin with the given language configs. * Only configs that have a `treeSitter` field will be loaded. * If no configs are provided, defaults to TypeScript and JavaScript. + * + * @param configs Language configurations to load + * @param extractors Optional language extractors; if none provided, registers TypeScriptExtractor by default */ - constructor(configs?: LanguageConfig[]) { + constructor(configs?: LanguageConfig[], extractors?: LanguageExtractor[]) { if (configs) { this.configs = configs.filter((c) => c.treeSitter); } else { @@ -199,6 +83,27 @@ export class TreeSitterPlugin implements AnalyzerPlugin { } this.languages = langs; + + // Register extractors (default: TypeScript/JavaScript) + if (extractors && extractors.length > 0) { + for (const extractor of extractors) { + this.registerExtractor(extractor); + } + } else { + this.registerExtractor(new TypeScriptExtractor()); + } + } + + registerExtractor(extractor: LanguageExtractor): void { + for (const id of extractor.languageIds) { + this.extractors.set(id, extractor); + } + } + + private getExtractor(langKey: string): LanguageExtractor | null { + // tsx is a synthetic grammar key — extraction logic is identical to typescript + const key = langKey === "tsx" ? "typescript" : langKey; + return this.extractors.get(key) ?? null; } private languageKeyFromPath(filePath: string): string | null { @@ -326,30 +231,20 @@ export class TreeSitterPlugin implements AnalyzerPlugin { return { functions: [], classes: [], imports: [], exports: [] }; } - const functions: StructuralAnalysis["functions"] = []; - const classes: StructuralAnalysis["classes"] = []; - const imports: StructuralAnalysis["imports"] = []; - const exports: StructuralAnalysis["exports"] = []; - const exportedNames = new Set(); + const langKey = this.languageKeyFromPath(filePath); + const extractor = langKey ? this.getExtractor(langKey) : null; - const root = tree.rootNode; - for (let i = 0; i < root.childCount; i++) { - const node = root.child(i); - if (!node) continue; - this.processTopLevelNode( - node, - functions, - classes, - imports, - exports, - exportedNames, - ); + let result: StructuralAnalysis; + if (extractor) { + result = extractor.extractStructure(tree.rootNode); + } else { + result = { functions: [], classes: [], imports: [], exports: [] }; } tree.delete(); parser.delete(); - return { functions, classes, imports, exports }; + return result; } resolveImports( @@ -390,357 +285,13 @@ export class TreeSitterPlugin implements AnalyzerPlugin { return []; } - const entries: CallGraphEntry[] = []; - const functionStack: string[] = []; - - const walkForCalls = (node: TreeSitterNode) => { - const isFunctionLike = - node.type === "function_declaration" || - node.type === "method_definition" || - node.type === "arrow_function" || - node.type === "function_expression"; - - let pushedName = false; - if (isFunctionLike) { - let name: string | undefined; - if (node.type === "function_declaration") { - name = ( - node.childForFieldName("name") ?? - node.children.find((c) => c.type === "identifier") - )?.text; - } else if (node.type === "method_definition") { - name = node.children.find( - (c) => c.type === "property_identifier", - )?.text; - } else if ( - node.type === "arrow_function" || - node.type === "function_expression" - ) { - const parent = node.parent; - if (parent && parent.type === "variable_declarator") { - name = parent.childForFieldName("name")?.text; - } - } - if (name) { - functionStack.push(name); - pushedName = true; - } - } - - if (node.type === "call_expression") { - const callee = node.childForFieldName("function"); - if (callee && functionStack.length > 0) { - entries.push({ - caller: functionStack[functionStack.length - 1], - callee: callee.text, - lineNumber: node.startPosition.row + 1, - }); - } - } - - for (let i = 0; i < node.childCount; i++) { - const child = node.child(i); - if (child) walkForCalls(child); - } - - if (pushedName) { - functionStack.pop(); - } - }; - - walkForCalls(tree.rootNode); + const langKey = this.languageKeyFromPath(filePath); + const extractor = langKey ? this.getExtractor(langKey) : null; + const result = extractor ? extractor.extractCallGraph(tree.rootNode) : []; tree.delete(); parser.delete(); - return entries; - } - - // ---- Private extraction helpers ---- - - private processTopLevelNode( - node: TreeSitterNode, - functions: StructuralAnalysis["functions"], - classes: StructuralAnalysis["classes"], - imports: StructuralAnalysis["imports"], - exports: StructuralAnalysis["exports"], - exportedNames: Set, - ): void { - switch (node.type) { - case "function_declaration": - this.extractFunction(node, functions); - break; - - case "class_declaration": - this.extractClass(node, classes); - break; - - case "lexical_declaration": - case "variable_declaration": - this.extractVariableDeclarations(node, functions); - break; - - case "import_statement": - this.extractImport(node, imports); - break; - - case "export_statement": - this.processExportStatement( - node, - functions, - classes, - imports, - exports, - exportedNames, - ); - break; - } - } - - private extractFunction( - node: TreeSitterNode, - functions: StructuralAnalysis["functions"], - ): void { - const nameNode = - node.childForFieldName("name") ?? - node.children.find((c) => c.type === "identifier"); - if (!nameNode) return; - - const params = extractParams( - node.childForFieldName("parameters") ?? - node.children.find( - (c) => c.type === "formal_parameters", - ) ?? - null, - ); - const returnType = extractReturnType(node); - - functions.push({ - name: nameNode.text, - lineRange: [ - node.startPosition.row + 1, - node.endPosition.row + 1, - ], - params, - returnType, - }); - } - - private extractClass( - node: TreeSitterNode, - classes: StructuralAnalysis["classes"], - ): void { - const nameNode = node.children.find( - (c) => - c.type === "type_identifier" || c.type === "identifier", - ); - if (!nameNode) return; - - const methods: string[] = []; - const properties: string[] = []; - - const classBody = node.children.find( - (c) => c.type === "class_body", - ); - if (classBody) { - for (let j = 0; j < classBody.childCount; j++) { - const member = classBody.child(j); - if (!member) continue; - - if (member.type === "method_definition") { - const methodName = member.children.find( - (c) => c.type === "property_identifier", - ); - if (methodName) methods.push(methodName.text); - } else if ( - member.type === "public_field_definition" || - member.type === "property_definition" - ) { - const propName = member.children.find( - (c) => c.type === "property_identifier", - ); - if (propName) properties.push(propName.text); - } - } - } - - classes.push({ - name: nameNode.text, - lineRange: [ - node.startPosition.row + 1, - node.endPosition.row + 1, - ], - methods, - properties, - }); - } - - private extractVariableDeclarations( - node: TreeSitterNode, - functions: StructuralAnalysis["functions"], - ): void { - for (let j = 0; j < node.childCount; j++) { - const child = node.child(j); - if (!child || child.type !== "variable_declarator") continue; - - const nameNode = child.childForFieldName("name"); - const valueNode = child.childForFieldName("value"); - - if ( - nameNode && - valueNode && - (valueNode.type === "arrow_function" || - valueNode.type === "function_expression" || - valueNode.type === "function") - ) { - const params = extractParams( - valueNode.childForFieldName("parameters") ?? - valueNode.children.find( - (c) => c.type === "formal_parameters", - ) ?? - null, - ); - const returnType = extractReturnType(valueNode); - - functions.push({ - name: nameNode.text, - lineRange: [ - node.startPosition.row + 1, - node.endPosition.row + 1, - ], - params, - returnType, - }); - } - } - } - - private extractImport( - node: TreeSitterNode, - imports: StructuralAnalysis["imports"], - ): void { - const sourceNode = node.children.find( - (c) => c.type === "string", - ); - if (!sourceNode) return; - - const source = getStringValue(sourceNode); - const specifiers: string[] = []; - - const importClause = node.children.find( - (c) => c.type === "import_clause", - ); - if (importClause) { - specifiers.push(...extractImportSpecifiers(importClause)); - } - - imports.push({ - source, - specifiers, - lineNumber: node.startPosition.row + 1, - }); - } - - private processExportStatement( - node: TreeSitterNode, - functions: StructuralAnalysis["functions"], - classes: StructuralAnalysis["classes"], - _imports: StructuralAnalysis["imports"], - exports: StructuralAnalysis["exports"], - exportedNames: Set, - ): void { - for (let j = 0; j < node.childCount; j++) { - const child = node.child(j); - if (!child) continue; - - switch (child.type) { - case "function_declaration": { - this.extractFunction(child, functions); - const nameNode = - child.childForFieldName("name") ?? - child.children.find((c) => c.type === "identifier"); - if (nameNode && !exportedNames.has(nameNode.text)) { - exports.push({ - name: nameNode.text, - lineNumber: node.startPosition.row + 1, - }); - exportedNames.add(nameNode.text); - } - break; - } - - case "class_declaration": { - this.extractClass(child, classes); - const nameNode = child.children.find( - (c) => - c.type === "type_identifier" || - c.type === "identifier", - ); - if (nameNode && !exportedNames.has(nameNode.text)) { - const isDefault = node.children.some( - (c) => c.type === "default", - ); - const exportName = isDefault - ? "default" - : nameNode.text; - exports.push({ - name: exportName, - lineNumber: node.startPosition.row + 1, - }); - exportedNames.add(exportName); - } - break; - } - - case "lexical_declaration": - case "variable_declaration": { - this.extractVariableDeclarations(child, functions); - for (let k = 0; k < child.childCount; k++) { - const declarator = child.child(k); - if ( - declarator && - declarator.type === "variable_declarator" - ) { - const nameNode = - declarator.childForFieldName("name"); - if ( - nameNode && - !exportedNames.has(nameNode.text) - ) { - exports.push({ - name: nameNode.text, - lineNumber: node.startPosition.row + 1, - }); - exportedNames.add(nameNode.text); - } - } - } - break; - } - - case "export_clause": { - for (let k = 0; k < child.childCount; k++) { - const spec = child.child(k); - if (spec && spec.type === "export_specifier") { - const alias = spec.childForFieldName("alias"); - const name = spec.childForFieldName("name"); - const exportName = alias - ? alias.text - : name - ? name.text - : spec.text; - if (!exportedNames.has(exportName)) { - exports.push({ - name: exportName, - lineNumber: node.startPosition.row + 1, - }); - exportedNames.add(exportName); - } - } - } - break; - } - } - } + return result; } } From 9376375c8c9fdeca0a9af0f60cf0551d59946a1b Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 18:20:31 +0800 Subject: [PATCH 05/18] feat: add extractCallGraph to PluginRegistry, derive DEFAULT_PLUGIN_CONFIG from configs Co-Authored-By: Claude Opus 4.6 (1M context) --- .../packages/core/src/plugins/discovery.ts | 6 +++++- .../packages/core/src/plugins/registry.ts | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/understand-anything-plugin/packages/core/src/plugins/discovery.ts b/understand-anything-plugin/packages/core/src/plugins/discovery.ts index 11d9a1a..ead32eb 100644 --- a/understand-anything-plugin/packages/core/src/plugins/discovery.ts +++ b/understand-anything-plugin/packages/core/src/plugins/discovery.ts @@ -1,3 +1,5 @@ +import { builtinLanguageConfigs } from "../languages/configs/index.js"; + export interface PluginEntry { name: string; enabled: boolean; @@ -14,7 +16,9 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { { name: "tree-sitter", enabled: true, - languages: ["typescript", "javascript"], + languages: builtinLanguageConfigs + .filter((c) => c.treeSitter) + .map((c) => c.id), }, ], }; diff --git a/understand-anything-plugin/packages/core/src/plugins/registry.ts b/understand-anything-plugin/packages/core/src/plugins/registry.ts index 67261f5..91ba343 100644 --- a/understand-anything-plugin/packages/core/src/plugins/registry.ts +++ b/understand-anything-plugin/packages/core/src/plugins/registry.ts @@ -1,4 +1,4 @@ -import type { AnalyzerPlugin, StructuralAnalysis, ImportResolution } from "../types.js"; +import type { AnalyzerPlugin, StructuralAnalysis, ImportResolution, CallGraphEntry } from "../types.js"; import { LanguageRegistry } from "../languages/language-registry.js"; /** @@ -65,6 +65,12 @@ export class PluginRegistry { return plugin.resolveImports(filePath, content); } + extractCallGraph(filePath: string, content: string): CallGraphEntry[] | null { + const plugin = this.getPluginForFile(filePath); + if (!plugin?.extractCallGraph) return null; + return plugin.extractCallGraph(filePath, content); + } + getPlugins(): AnalyzerPlugin[] { return [...this.plugins]; } From 2ad05638783b25781e1e88f8b8dc4c85b8b1e2ef Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 18:22:51 +0800 Subject: [PATCH 06/18] feat: add tree-sitter grammar deps and treeSitter configs for 10 languages Co-Authored-By: Claude Opus 4.6 (1M context) --- pnpm-lock.yaml | 142 ++++++++++++++++++ .../packages/core/package.json | 8 + .../core/src/languages/configs/cpp.ts | 4 + .../core/src/languages/configs/csharp.ts | 4 + .../packages/core/src/languages/configs/go.ts | 4 + .../core/src/languages/configs/java.ts | 4 + .../core/src/languages/configs/php.ts | 4 + .../core/src/languages/configs/python.ts | 4 + .../core/src/languages/configs/ruby.ts | 4 + .../core/src/languages/configs/rust.ts | 4 + 10 files changed, 182 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d01ba6a..1d5c2b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,9 +45,33 @@ importers: ignore: specifier: ^7.0.5 version: 7.0.5 + tree-sitter-c-sharp: + specifier: ^0.23.1 + version: 0.23.5 + tree-sitter-cpp: + specifier: ^0.23.4 + version: 0.23.4 + tree-sitter-go: + specifier: ^0.25.0 + version: 0.25.0 + tree-sitter-java: + specifier: ^0.23.5 + version: 0.23.5 tree-sitter-javascript: specifier: ^0.25.0 version: 0.25.0 + tree-sitter-php: + specifier: ^0.23.11 + version: 0.23.12 + tree-sitter-python: + specifier: ^0.25.0 + version: 0.25.0 + tree-sitter-ruby: + specifier: ^0.23.1 + version: 0.23.1 + tree-sitter-rust: + specifier: ^0.24.0 + version: 0.24.0 tree-sitter-typescript: specifier: ^0.23.2 version: 0.23.2 @@ -2353,6 +2377,46 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tree-sitter-c-sharp@0.23.5: + resolution: {integrity: sha512-xJGOeXPMmld0nES5+080N/06yY6LQi+KWGWV4LfZaZe6srJPtUtfhIbRSN7EZN6IaauzW28v6W4QHFwmeUW6HQ==} + peerDependencies: + tree-sitter: ^0.25.0 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-c@0.23.6: + resolution: {integrity: sha512-0dxXKznVyUA0s6PjNolJNs2yF87O5aL538A/eR6njA5oqX3C3vH4vnx3QdOKwuUdpKEcFdHuiDpRKLLCA/tjvQ==} + peerDependencies: + tree-sitter: ^0.22.1 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-cpp@0.23.4: + resolution: {integrity: sha512-qR5qUDyhZ5jJ6V8/umiBxokRbe89bCGmcq/dk94wI4kN86qfdV8k0GHIUEKaqWgcu42wKal5E97LKpLeVW8sKw==} + peerDependencies: + tree-sitter: ^0.21.1 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-go@0.25.0: + resolution: {integrity: sha512-APBc/Dq3xz/e35Xpkhb1blu5UgW+2E3RyGWawZSCNcbGwa7jhSQPS8KsUupuzBla8PCo8+lz9W/JDJjmfRa2tw==} + peerDependencies: + tree-sitter: ^0.25.0 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-java@0.23.5: + resolution: {integrity: sha512-Yju7oQ0Xx7GcUT01mUglPP+bYfvqjNCGdxqigTnew9nLGoII42PNVP3bHrYeMxswiCRM0yubWmN5qk+zsg0zMA==} + peerDependencies: + tree-sitter: ^0.21.1 + peerDependenciesMeta: + tree-sitter: + optional: true + tree-sitter-javascript@0.23.1: resolution: {integrity: sha512-/bnhbrTD9frUYHQTiYnPcxyHORIw157ERBa6dqzaKxvR/x3PC4Yzd+D1pZIMS6zNg2v3a8BZ0oK7jHqsQo9fWA==} peerDependencies: @@ -2369,6 +2433,38 @@ packages: tree-sitter: optional: true + tree-sitter-php@0.23.12: + resolution: {integrity: sha512-VwkBVOahhC2NYXK/Fuqq30NxuL/6c2hmbxEF4jrB7AyR5rLc7nT27mzF3qoi+pqx9Gy2AbXnGezF7h4MeM6YRA==} + peerDependencies: + tree-sitter: ^0.21.1 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-python@0.25.0: + resolution: {integrity: sha512-eCmJx6zQa35GxaCtQD+wXHOhYqBxEL+bp71W/s3fcDMu06MrtzkVXR437dRrCrbrDbyLuUDJpAgycs7ncngLXw==} + peerDependencies: + tree-sitter: ^0.25.0 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-ruby@0.23.1: + resolution: {integrity: sha512-d9/RXgWjR6HanN7wTYhS5bpBQLz1VkH048Vm3CodPGyJVnamXMGb8oEhDypVCBq4QnHui9sTXuJBBP3WtCw5RA==} + peerDependencies: + tree-sitter: ^0.21.1 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-rust@0.24.0: + resolution: {integrity: sha512-NWemUDf629Tfc90Y0Z55zuwPCAHkLxWnMf2RznYu4iBkkrQl2o/CHGB7Cr52TyN5F1DAx8FmUnDtCy9iUkXZEQ==} + peerDependencies: + tree-sitter: ^0.22.1 + peerDependenciesMeta: + tree-sitter: + optional: true + tree-sitter-typescript@0.23.2: resolution: {integrity: sha512-e04JUUKxTT53/x3Uq1zIL45DoYKVfHH4CZqwgZhPg5qYROl5nQjV+85ruFzFGZxu+QeFVbRTPDRnqL9UbU4VeA==} peerDependencies: @@ -5220,6 +5316,32 @@ snapshots: tinyspy@4.0.4: {} + tree-sitter-c-sharp@0.23.5: + dependencies: + node-addon-api: 8.6.0 + node-gyp-build: 4.8.4 + + tree-sitter-c@0.23.6: + dependencies: + node-addon-api: 8.6.0 + node-gyp-build: 4.8.4 + + tree-sitter-cpp@0.23.4: + dependencies: + node-addon-api: 8.6.0 + node-gyp-build: 4.8.4 + tree-sitter-c: 0.23.6 + + tree-sitter-go@0.25.0: + dependencies: + node-addon-api: 8.6.0 + node-gyp-build: 4.8.4 + + tree-sitter-java@0.23.5: + dependencies: + node-addon-api: 8.6.0 + node-gyp-build: 4.8.4 + tree-sitter-javascript@0.23.1: dependencies: node-addon-api: 8.6.0 @@ -5230,6 +5352,26 @@ snapshots: node-addon-api: 8.6.0 node-gyp-build: 4.8.4 + tree-sitter-php@0.23.12: + dependencies: + node-addon-api: 8.6.0 + node-gyp-build: 4.8.4 + + tree-sitter-python@0.25.0: + dependencies: + node-addon-api: 8.6.0 + node-gyp-build: 4.8.4 + + tree-sitter-ruby@0.23.1: + dependencies: + node-addon-api: 8.6.0 + node-gyp-build: 4.8.4 + + tree-sitter-rust@0.24.0: + dependencies: + node-addon-api: 8.6.0 + node-gyp-build: 4.8.4 + tree-sitter-typescript@0.23.2: dependencies: node-addon-api: 8.6.0 diff --git a/understand-anything-plugin/packages/core/package.json b/understand-anything-plugin/packages/core/package.json index 2318ddc..c4cd4f3 100644 --- a/understand-anything-plugin/packages/core/package.json +++ b/understand-anything-plugin/packages/core/package.json @@ -39,7 +39,15 @@ "dependencies": { "fuse.js": "^7.1.0", "ignore": "^7.0.5", + "tree-sitter-c-sharp": "^0.23.1", + "tree-sitter-cpp": "^0.23.4", + "tree-sitter-go": "^0.25.0", + "tree-sitter-java": "^0.23.5", "tree-sitter-javascript": "^0.25.0", + "tree-sitter-php": "^0.23.11", + "tree-sitter-python": "^0.25.0", + "tree-sitter-ruby": "^0.23.1", + "tree-sitter-rust": "^0.24.0", "tree-sitter-typescript": "^0.23.2", "web-tree-sitter": "^0.26.6", "yaml": "^2.8.3", diff --git a/understand-anything-plugin/packages/core/src/languages/configs/cpp.ts b/understand-anything-plugin/packages/core/src/languages/configs/cpp.ts index e4aed53..e9670cb 100644 --- a/understand-anything-plugin/packages/core/src/languages/configs/cpp.ts +++ b/understand-anything-plugin/packages/core/src/languages/configs/cpp.ts @@ -4,6 +4,10 @@ export const cppConfig = { id: "cpp", displayName: "C/C++", extensions: [".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", ".hxx"], + treeSitter: { + wasmPackage: "tree-sitter-cpp", + wasmFile: "tree-sitter-cpp.wasm", + }, concepts: [ "templates", "RAII", diff --git a/understand-anything-plugin/packages/core/src/languages/configs/csharp.ts b/understand-anything-plugin/packages/core/src/languages/configs/csharp.ts index ba2a864..598fe8f 100644 --- a/understand-anything-plugin/packages/core/src/languages/configs/csharp.ts +++ b/understand-anything-plugin/packages/core/src/languages/configs/csharp.ts @@ -4,6 +4,10 @@ export const csharpConfig = { id: "csharp", displayName: "C#", extensions: [".cs"], + treeSitter: { + wasmPackage: "tree-sitter-c-sharp", + wasmFile: "tree-sitter-c_sharp.wasm", + }, concepts: [ "LINQ", "async/await", diff --git a/understand-anything-plugin/packages/core/src/languages/configs/go.ts b/understand-anything-plugin/packages/core/src/languages/configs/go.ts index 3d0f2a6..2119168 100644 --- a/understand-anything-plugin/packages/core/src/languages/configs/go.ts +++ b/understand-anything-plugin/packages/core/src/languages/configs/go.ts @@ -4,6 +4,10 @@ export const goConfig = { id: "go", displayName: "Go", extensions: [".go"], + treeSitter: { + wasmPackage: "tree-sitter-go", + wasmFile: "tree-sitter-go.wasm", + }, concepts: [ "goroutines", "channels", diff --git a/understand-anything-plugin/packages/core/src/languages/configs/java.ts b/understand-anything-plugin/packages/core/src/languages/configs/java.ts index cc62fa4..29715d5 100644 --- a/understand-anything-plugin/packages/core/src/languages/configs/java.ts +++ b/understand-anything-plugin/packages/core/src/languages/configs/java.ts @@ -4,6 +4,10 @@ export const javaConfig = { id: "java", displayName: "Java", extensions: [".java"], + treeSitter: { + wasmPackage: "tree-sitter-java", + wasmFile: "tree-sitter-java.wasm", + }, concepts: [ "generics", "annotations", diff --git a/understand-anything-plugin/packages/core/src/languages/configs/php.ts b/understand-anything-plugin/packages/core/src/languages/configs/php.ts index 2dab065..c318ba0 100644 --- a/understand-anything-plugin/packages/core/src/languages/configs/php.ts +++ b/understand-anything-plugin/packages/core/src/languages/configs/php.ts @@ -4,6 +4,10 @@ export const phpConfig = { id: "php", displayName: "PHP", extensions: [".php"], + treeSitter: { + wasmPackage: "tree-sitter-php", + wasmFile: "tree-sitter-php.wasm", + }, concepts: [ "namespaces", "traits", diff --git a/understand-anything-plugin/packages/core/src/languages/configs/python.ts b/understand-anything-plugin/packages/core/src/languages/configs/python.ts index f5fa0b0..799b0ab 100644 --- a/understand-anything-plugin/packages/core/src/languages/configs/python.ts +++ b/understand-anything-plugin/packages/core/src/languages/configs/python.ts @@ -4,6 +4,10 @@ export const pythonConfig = { id: "python", displayName: "Python", extensions: [".py", ".pyi"], + treeSitter: { + wasmPackage: "tree-sitter-python", + wasmFile: "tree-sitter-python.wasm", + }, concepts: [ "decorators", "list comprehensions", diff --git a/understand-anything-plugin/packages/core/src/languages/configs/ruby.ts b/understand-anything-plugin/packages/core/src/languages/configs/ruby.ts index f3ed999..9629617 100644 --- a/understand-anything-plugin/packages/core/src/languages/configs/ruby.ts +++ b/understand-anything-plugin/packages/core/src/languages/configs/ruby.ts @@ -4,6 +4,10 @@ export const rubyConfig = { id: "ruby", displayName: "Ruby", extensions: [".rb", ".rake"], + treeSitter: { + wasmPackage: "tree-sitter-ruby", + wasmFile: "tree-sitter-ruby.wasm", + }, concepts: [ "blocks and procs", "mixins", diff --git a/understand-anything-plugin/packages/core/src/languages/configs/rust.ts b/understand-anything-plugin/packages/core/src/languages/configs/rust.ts index 3a6a4bb..b879506 100644 --- a/understand-anything-plugin/packages/core/src/languages/configs/rust.ts +++ b/understand-anything-plugin/packages/core/src/languages/configs/rust.ts @@ -4,6 +4,10 @@ export const rustConfig = { id: "rust", displayName: "Rust", extensions: [".rs"], + treeSitter: { + wasmPackage: "tree-sitter-rust", + wasmFile: "tree-sitter-rust.wasm", + }, concepts: [ "ownership", "borrowing", From 398f5b15b009ebf0ca3d4ac3fbfcaf2a6afa1277 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 18:28:38 +0800 Subject: [PATCH 07/18] feat: add PythonExtractor for tree-sitter Python structural analysis Implements the LanguageExtractor interface for Python, extracting functions (with type annotations, defaults, *args/**kwargs), classes (methods + annotated properties), imports (plain, from, aliased, wildcard), exports (top-level defs), and caller-callee call graphs. Includes 31 tests using the real tree-sitter parser. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/python-extractor.test.ts | 659 ++++++++++++++++++ .../plugins/extractors/python-extractor.ts | 353 ++++++++++ 2 files changed, 1012 insertions(+) create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/python-extractor.test.ts create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/python-extractor.ts diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/python-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/python-extractor.test.ts new file mode 100644 index 0000000..3929b05 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/python-extractor.test.ts @@ -0,0 +1,659 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { createRequire } from "node:module"; +import { PythonExtractor } from "../python-extractor.js"; + +const require = createRequire(import.meta.url); + +// Load tree-sitter + Python grammar once +let Parser: any; +let Language: any; +let pythonLang: any; + +beforeAll(async () => { + const mod = await import("web-tree-sitter"); + Parser = mod.Parser; + Language = mod.Language; + await Parser.init(); + const wasmPath = require.resolve( + "tree-sitter-python/tree-sitter-python.wasm", + ); + pythonLang = await Language.load(wasmPath); +}); + +function parse(code: string) { + const parser = new Parser(); + parser.setLanguage(pythonLang); + const tree = parser.parse(code); + const root = tree.rootNode; + return { tree, parser, root }; +} + +describe("PythonExtractor", () => { + const extractor = new PythonExtractor(); + + it("has correct languageIds", () => { + expect(extractor.languageIds).toEqual(["python"]); + }); + + // ---- Functions ---- + + describe("extractStructure - functions", () => { + it("extracts simple functions with type annotations", () => { + const { tree, parser, root } = parse(` +def hello(name: str) -> str: + return f"Hello {name}" + +def add(a: int, b: int) -> int: + return a + b +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(2); + + expect(result.functions[0].name).toBe("hello"); + expect(result.functions[0].params).toEqual(["name"]); + expect(result.functions[0].returnType).toBe("str"); + expect(result.functions[0].lineRange[0]).toBeGreaterThan(0); + + expect(result.functions[1].name).toBe("add"); + expect(result.functions[1].params).toEqual(["a", "b"]); + expect(result.functions[1].returnType).toBe("int"); + + tree.delete(); + parser.delete(); + }); + + it("extracts functions without type annotations", () => { + const { tree, parser, root } = parse(` +def greet(name): + print(name) + +def noop(): + pass +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(2); + expect(result.functions[0].name).toBe("greet"); + expect(result.functions[0].params).toEqual(["name"]); + expect(result.functions[0].returnType).toBeUndefined(); + + expect(result.functions[1].name).toBe("noop"); + expect(result.functions[1].params).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("extracts functions with default parameters", () => { + const { tree, parser, root } = parse(` +def connect(host: str, port: int = 8080, timeout: float = 30.0): + pass +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("connect"); + expect(result.functions[0].params).toEqual(["host", "port", "timeout"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts functions with *args and **kwargs", () => { + const { tree, parser, root } = parse(` +def flexible(*args, **kwargs): + pass +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].params).toEqual(["*args", "**kwargs"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts decorated functions", () => { + const { tree, parser, root } = parse(` +@decorator +def decorated_func(): + pass + +@app.route("/api") +def api_handler(): + pass +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(2); + expect(result.functions[0].name).toBe("decorated_func"); + expect(result.functions[1].name).toBe("api_handler"); + + tree.delete(); + parser.delete(); + }); + + it("reports correct line ranges", () => { + const { tree, parser, root } = parse(` +def multiline( + a: int, + b: int, +) -> int: + result = a + b + return result +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].lineRange[0]).toBe(2); + expect(result.functions[0].lineRange[1]).toBe(7); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Classes ---- + + describe("extractStructure - classes", () => { + it("extracts classes with methods and properties", () => { + const { tree, parser, root } = parse(` +class DataProcessor: + name: str + + def __init__(self, name: str): + self.name = name + + def process(self, data: list) -> dict: + return transform(data) +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("DataProcessor"); + expect(result.classes[0].methods).toContain("__init__"); + expect(result.classes[0].methods).toContain("process"); + expect(result.classes[0].properties).toContain("name"); + + tree.delete(); + parser.delete(); + }); + + it("extracts dataclass-style annotated properties", () => { + const { tree, parser, root } = parse(` +class Config: + name: str + value: int + debug: bool +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].properties).toEqual(["name", "value", "debug"]); + expect(result.classes[0].methods).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("extracts decorated classes", () => { + const { tree, parser, root } = parse(` +@dataclass +class Config: + name: str + value: int = 0 +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Config"); + expect(result.classes[0].properties).toContain("name"); + expect(result.classes[0].properties).toContain("value"); + + tree.delete(); + parser.delete(); + }); + + it("extracts decorated methods within a class", () => { + const { tree, parser, root } = parse(` +class MyClass: + @staticmethod + def static_method(): + pass + + @classmethod + def class_method(cls): + pass + + @property + def prop(self): + return self._prop +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].methods).toContain("static_method"); + expect(result.classes[0].methods).toContain("class_method"); + expect(result.classes[0].methods).toContain("prop"); + + tree.delete(); + parser.delete(); + }); + + it("filters self and cls from method params", () => { + const { tree, parser, root } = parse(` +class Foo: + def instance_method(self, x: int): + pass + + @classmethod + def class_method(cls, y: str): + pass +`); + const result = extractor.extractStructure(root); + // Methods are on the class, but top-level functions should not include them + expect(result.functions).toHaveLength(0); + expect(result.classes[0].methods).toEqual(["instance_method", "class_method"]); + + tree.delete(); + parser.delete(); + }); + + it("reports correct class line ranges", () => { + const { tree, parser, root } = parse(` +class MyClass: + def method_a(self): + pass + + def method_b(self): + pass +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].lineRange[0]).toBe(2); + expect(result.classes[0].lineRange[1]).toBe(7); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Imports ---- + + describe("extractStructure - imports", () => { + it("extracts simple import statements", () => { + const { tree, parser, root } = parse(` +import os +import sys +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("os"); + expect(result.imports[0].specifiers).toEqual(["os"]); + expect(result.imports[1].source).toBe("sys"); + expect(result.imports[1].specifiers).toEqual(["sys"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts from-import statements", () => { + const { tree, parser, root } = parse(` +from pathlib import Path +from typing import Optional, List +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("pathlib"); + expect(result.imports[0].specifiers).toEqual(["Path"]); + expect(result.imports[1].source).toBe("typing"); + expect(result.imports[1].specifiers).toEqual(["Optional", "List"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts aliased imports", () => { + const { tree, parser, root } = parse(` +from foo import bar as baz +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("foo"); + expect(result.imports[0].specifiers).toEqual(["baz"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts dotted module imports", () => { + const { tree, parser, root } = parse(` +import os.path +from os.path import join, exists +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("os.path"); + expect(result.imports[0].specifiers).toEqual(["os.path"]); + expect(result.imports[1].source).toBe("os.path"); + expect(result.imports[1].specifiers).toEqual(["join", "exists"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts wildcard imports", () => { + const { tree, parser, root } = parse(` +from os.path import * +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("os.path"); + expect(result.imports[0].specifiers).toEqual(["*"]); + + tree.delete(); + parser.delete(); + }); + + it("handles all import types together", () => { + const { tree, parser, root } = parse(` +import os +from pathlib import Path +from typing import Optional, List +`); + const result = extractor.extractStructure(root); + + expect(result.imports.length).toBeGreaterThanOrEqual(3); + + tree.delete(); + parser.delete(); + }); + + it("reports correct import line numbers", () => { + const { tree, parser, root } = parse(` +import os +from pathlib import Path +`); + const result = extractor.extractStructure(root); + + expect(result.imports[0].lineNumber).toBe(2); + expect(result.imports[1].lineNumber).toBe(3); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Exports ---- + + describe("extractStructure - exports", () => { + it("treats top-level functions as exports", () => { + const { tree, parser, root } = parse(` +def public_func(): + pass + +def another_func(x: int) -> str: + return str(x) +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("public_func"); + expect(exportNames).toContain("another_func"); + expect(result.exports).toHaveLength(2); + + tree.delete(); + parser.delete(); + }); + + it("treats top-level classes as exports", () => { + const { tree, parser, root } = parse(` +class MyService: + pass + +class MyModel: + pass +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("MyService"); + expect(exportNames).toContain("MyModel"); + expect(result.exports).toHaveLength(2); + + tree.delete(); + parser.delete(); + }); + + it("treats decorated top-level definitions as exports", () => { + const { tree, parser, root } = parse(` +@dataclass +class Config: + name: str + +@app.route("/") +def index(): + pass +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Config"); + expect(exportNames).toContain("index"); + + tree.delete(); + parser.delete(); + }); + + it("does not treat imports as exports", () => { + const { tree, parser, root } = parse(` +import os +from pathlib import Path + +def my_func(): + pass +`); + const result = extractor.extractStructure(root); + + expect(result.exports).toHaveLength(1); + expect(result.exports[0].name).toBe("my_func"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Call Graph ---- + + describe("extractCallGraph", () => { + it("extracts simple function calls", () => { + const { tree, parser, root } = parse(` +def process(data): + result = transform(data) + return format_output(result) + +def main(): + process([1, 2, 3]) +`); + const result = extractor.extractCallGraph(root); + + expect(result.length).toBeGreaterThanOrEqual(2); + + const processCallers = result.filter((e) => e.caller === "process"); + expect(processCallers.some((e) => e.callee === "transform")).toBe(true); + expect(processCallers.some((e) => e.callee === "format_output")).toBe(true); + + const mainCallers = result.filter((e) => e.caller === "main"); + expect(mainCallers.some((e) => e.callee === "process")).toBe(true); + + tree.delete(); + parser.delete(); + }); + + it("extracts attribute-based calls (method calls)", () => { + const { tree, parser, root } = parse(` +def process(): + self.method() + os.path.join("a", "b") + result.save() +`); + const result = extractor.extractCallGraph(root); + + const callees = result.map((e) => e.callee); + expect(callees).toContain("self.method"); + expect(callees).toContain("os.path.join"); + expect(callees).toContain("result.save"); + + tree.delete(); + parser.delete(); + }); + + it("tracks correct caller context for nested calls", () => { + const { tree, parser, root } = parse(` +def outer(): + helper() + def inner(): + deep_call() + another() +`); + const result = extractor.extractCallGraph(root); + + const outerCalls = result.filter((e) => e.caller === "outer"); + expect(outerCalls.some((e) => e.callee === "helper")).toBe(true); + expect(outerCalls.some((e) => e.callee === "another")).toBe(true); + + const innerCalls = result.filter((e) => e.caller === "inner"); + expect(innerCalls.some((e) => e.callee === "deep_call")).toBe(true); + + tree.delete(); + parser.delete(); + }); + + it("reports correct line numbers for calls", () => { + const { tree, parser, root } = parse(` +def main(): + foo() + bar() +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(2); + expect(result[0].lineNumber).toBe(3); + expect(result[1].lineNumber).toBe(4); + + tree.delete(); + parser.delete(); + }); + + it("ignores top-level calls (no caller)", () => { + const { tree, parser, root } = parse(` +print("hello") +main() +`); + const result = extractor.extractCallGraph(root); + + // Top-level calls have no enclosing function, so they are skipped + expect(result).toHaveLength(0); + + tree.delete(); + parser.delete(); + }); + + it("handles calls inside class methods", () => { + const { tree, parser, root } = parse(` +class Service: + def start(self): + self.setup() + run_server() +`); + const result = extractor.extractCallGraph(root); + + const startCalls = result.filter((e) => e.caller === "start"); + expect(startCalls.some((e) => e.callee === "self.setup")).toBe(true); + expect(startCalls.some((e) => e.callee === "run_server")).toBe(true); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Comprehensive ---- + + describe("comprehensive Python file", () => { + it("handles a realistic Python module", () => { + const { tree, parser, root } = parse(` +import os +from pathlib import Path +from typing import Optional, List + +class FileProcessor: + name: str + verbose: bool + + def __init__(self, name: str, verbose: bool = False): + self.name = name + self.verbose = verbose + + def process(self, paths: List[str]) -> dict: + results = {} + for p in paths: + results[p] = self._read_file(p) + return results + + def _read_file(self, path: str) -> Optional[str]: + full = Path(path) + if full.exists(): + return full.read_text() + return None + +def create_processor(name: str) -> FileProcessor: + return FileProcessor(name) + +@staticmethod +def utility_func(*args, **kwargs) -> None: + print(args, kwargs) +`); + const result = extractor.extractStructure(root); + + // Imports + expect(result.imports.length).toBeGreaterThanOrEqual(3); + + // Class + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("FileProcessor"); + expect(result.classes[0].methods).toContain("__init__"); + expect(result.classes[0].methods).toContain("process"); + expect(result.classes[0].methods).toContain("_read_file"); + expect(result.classes[0].properties).toContain("name"); + expect(result.classes[0].properties).toContain("verbose"); + + // Top-level functions + expect(result.functions.some((f) => f.name === "create_processor")).toBe( + true, + ); + expect(result.functions.some((f) => f.name === "utility_func")).toBe( + true, + ); + + // Exports (top-level defs) + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("FileProcessor"); + expect(exportNames).toContain("create_processor"); + expect(exportNames).toContain("utility_func"); + + // Call graph + const calls = extractor.extractCallGraph(root); + expect(calls.length).toBeGreaterThan(0); + + tree.delete(); + parser.delete(); + }); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/python-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/python-extractor.ts new file mode 100644 index 0000000..83ae76c --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/python-extractor.ts @@ -0,0 +1,353 @@ +import type { StructuralAnalysis, CallGraphEntry } from "../../types.js"; +import type { LanguageExtractor, TreeSitterNode } from "./types.js"; +import { findChild, findChildren } from "./base-extractor.js"; + +/** + * Extract parameter names from a Python `parameters` node. + * + * Handles: identifier (plain), typed_parameter, default_parameter, + * typed_default_parameter, list_splat_pattern (*args), + * dictionary_splat_pattern (**kwargs). + */ +function extractParams(paramsNode: TreeSitterNode | null): string[] { + if (!paramsNode) return []; + const params: string[] = []; + + for (let i = 0; i < paramsNode.childCount; i++) { + const child = paramsNode.child(i); + if (!child) continue; + + switch (child.type) { + case "identifier": + // Skip `self` and `cls` — they are implicit, not real parameters + if (child.text !== "self" && child.text !== "cls") { + params.push(child.text); + } + break; + + case "typed_parameter": { + const ident = findChild(child, "identifier"); + if (ident && ident.text !== "self" && ident.text !== "cls") { + params.push(ident.text); + } + break; + } + + case "default_parameter": { + const ident = findChild(child, "identifier"); + if (ident && ident.text !== "self" && ident.text !== "cls") { + params.push(ident.text); + } + break; + } + + case "typed_default_parameter": { + const ident = findChild(child, "identifier"); + if (ident && ident.text !== "self" && ident.text !== "cls") { + params.push(ident.text); + } + break; + } + + case "list_splat_pattern": { + const ident = findChild(child, "identifier"); + if (ident) params.push("*" + ident.text); + break; + } + + case "dictionary_splat_pattern": { + const ident = findChild(child, "identifier"); + if (ident) params.push("**" + ident.text); + break; + } + } + } + + return params; +} + +/** + * Extract the return type annotation from a function_definition node. + * Python AST has a `return_type` field (the `type` node after `->`) on function_definition. + */ +function extractReturnType(node: TreeSitterNode): string | undefined { + const returnType = node.childForFieldName("return_type"); + if (returnType) { + return returnType.text; + } + return undefined; +} + +/** + * Unwrap a `decorated_definition` to get the inner definition. + * If the node is not a decorated_definition, returns the node itself. + */ +function unwrapDecorated(node: TreeSitterNode): TreeSitterNode { + if (node.type === "decorated_definition") { + const inner = + findChild(node, "function_definition") ?? + findChild(node, "class_definition"); + if (inner) return inner; + } + return node; +} + +/** + * Python extractor for tree-sitter structural analysis and call graph extraction. + * + * Handles functions, classes, imports, exports, and call graphs for Python code. + * Python has no formal export syntax, so all top-level function and class + * definitions are treated as exports. + */ +export class PythonExtractor implements LanguageExtractor { + readonly languageIds = ["python"]; + + extractStructure(rootNode: TreeSitterNode): StructuralAnalysis { + const functions: StructuralAnalysis["functions"] = []; + const classes: StructuralAnalysis["classes"] = []; + const imports: StructuralAnalysis["imports"] = []; + const exports: StructuralAnalysis["exports"] = []; + + for (let i = 0; i < rootNode.childCount; i++) { + const node = rootNode.child(i); + if (!node) continue; + + // Unwrap decorated definitions to get the inner node + const inner = unwrapDecorated(node); + + switch (inner.type) { + case "function_definition": + this.extractFunction(inner, functions); + // Top-level functions are exports in Python + this.addExport(inner, node, exports); + break; + + case "class_definition": + this.extractClass(inner, classes); + // Top-level classes are exports in Python + this.addExport(inner, node, exports); + break; + + case "import_statement": + this.extractImport(inner, imports); + break; + + case "import_from_statement": + this.extractFromImport(inner, imports); + break; + } + } + + return { functions, classes, imports, exports }; + } + + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] { + const entries: CallGraphEntry[] = []; + const functionStack: string[] = []; + + const walkForCalls = (node: TreeSitterNode) => { + let pushedName = false; + + // Track entering function/method definitions + if (node.type === "function_definition") { + const nameNode = node.childForFieldName("name"); + if (nameNode) { + functionStack.push(nameNode.text); + pushedName = true; + } + } + + // Extract call expressions + if (node.type === "call") { + const calleeNode = node.children.find( + (c) => + c.type === "identifier" || + c.type === "attribute", + ); + if (calleeNode && functionStack.length > 0) { + entries.push({ + caller: functionStack[functionStack.length - 1], + callee: calleeNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) walkForCalls(child); + } + + if (pushedName) { + functionStack.pop(); + } + }; + + walkForCalls(rootNode); + + return entries; + } + + // ---- Private helpers ---- + + private extractFunction( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const paramsNode = node.childForFieldName("parameters"); + const params = extractParams(paramsNode ?? null); + const returnType = extractReturnType(node); + + functions.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + params, + returnType, + }); + } + + private extractClass( + node: TreeSitterNode, + classes: StructuralAnalysis["classes"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const methods: string[] = []; + const properties: string[] = []; + + const body = node.childForFieldName("body"); + if (body) { + for (let i = 0; i < body.childCount; i++) { + const member = body.child(i); + if (!member) continue; + + // Methods: function_definition or decorated_definition wrapping a function_definition + const innerMember = unwrapDecorated(member); + if (innerMember.type === "function_definition") { + const methodName = innerMember.childForFieldName("name"); + if (methodName) methods.push(methodName.text); + } + + // Properties: type-annotated assignments at class body level + // e.g., `name: str` or `value: int = 0` + if (member.type === "expression_statement") { + const assignment = findChild(member, "assignment"); + if (assignment) { + // Check if this is a type-annotated class-level assignment (has `:` child = type annotation) + const typeNode = findChild(assignment, "type"); + const nameIdent = findChild(assignment, "identifier"); + if (typeNode && nameIdent) { + properties.push(nameIdent.text); + } + } + } + } + } + + classes.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + methods, + properties, + }); + } + + private extractImport( + node: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + // `import os` or `import os.path` + // Can have multiple: `import os, sys` + const dottedNames = findChildren(node, "dotted_name"); + const aliasedImports = findChildren(node, "aliased_import"); + + for (const dn of dottedNames) { + imports.push({ + source: dn.text, + specifiers: [dn.text], + lineNumber: node.startPosition.row + 1, + }); + } + + for (const ai of aliasedImports) { + const dottedName = findChild(ai, "dotted_name"); + const alias = ai.children.find( + (c) => c.type === "identifier", + ); + if (dottedName) { + imports.push({ + source: dottedName.text, + specifiers: [alias ? alias.text : dottedName.text], + lineNumber: node.startPosition.row + 1, + }); + } + } + } + + private extractFromImport( + node: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + // `from pathlib import Path` or `from typing import Optional, List` + const moduleNode = node.childForFieldName("module_name"); + const source = moduleNode ? moduleNode.text : ""; + const moduleNodeId = moduleNode?.id; + + const specifiers: string[] = []; + + // Collect dotted_name specifiers (non-aliased) + // Skip the module_name dotted_name (compare by node id, not reference) + const allDottedNames = findChildren(node, "dotted_name"); + for (const dn of allDottedNames) { + if (dn.id === moduleNodeId) continue; + specifiers.push(dn.text); + } + + // Collect aliased imports: `from foo import bar as baz` + const aliasedImports = findChildren(node, "aliased_import"); + for (const ai of aliasedImports) { + // The alias identifier follows the `as` keyword + const alias = ai.children.find( + (c) => c.type === "identifier", + ); + if (alias) { + specifiers.push(alias.text); + } + } + + // Handle wildcard imports: `from os import *` + if (findChild(node, "wildcard_import")) { + specifiers.push("*"); + } + + imports.push({ + source, + specifiers, + lineNumber: node.startPosition.row + 1, + }); + } + + private addExport( + inner: TreeSitterNode, + outer: TreeSitterNode, + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = inner.childForFieldName("name"); + if (nameNode) { + exports.push({ + name: nameNode.text, + lineNumber: outer.startPosition.row + 1, + }); + } + } +} From f18cad61d5d72626c4fb032ca13f8c6b1f144169 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 18:33:18 +0800 Subject: [PATCH 08/18] feat: add GoExtractor for tree-sitter Go structural analysis Implements the LanguageExtractor interface for Go, handling functions, methods with receivers, structs, interfaces, imports, exports (via capitalization convention), and call graph extraction. Includes 25 tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../extractors/__tests__/go-extractor.test.ts | 599 ++++++++++++++++++ .../src/plugins/extractors/go-extractor.ts | 402 ++++++++++++ 2 files changed, 1001 insertions(+) create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts new file mode 100644 index 0000000..2cf1b77 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts @@ -0,0 +1,599 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { createRequire } from "node:module"; +import { GoExtractor } from "../go-extractor.js"; + +const require = createRequire(import.meta.url); + +// Load tree-sitter + Go grammar once +let Parser: any; +let Language: any; +let goLang: any; + +beforeAll(async () => { + const mod = await import("web-tree-sitter"); + Parser = mod.Parser; + Language = mod.Language; + await Parser.init(); + const wasmPath = require.resolve( + "tree-sitter-go/tree-sitter-go.wasm", + ); + goLang = await Language.load(wasmPath); +}); + +function parse(code: string) { + const parser = new Parser(); + parser.setLanguage(goLang); + const tree = parser.parse(code); + const root = tree.rootNode; + return { tree, parser, root }; +} + +describe("GoExtractor", () => { + const extractor = new GoExtractor(); + + it("has correct languageIds", () => { + expect(extractor.languageIds).toEqual(["go"]); + }); + + // ---- Functions ---- + + describe("extractStructure - functions", () => { + it("extracts functions with params and return types", () => { + const { tree, parser, root } = parse(`package main + +func NewServer(host string, port int) *Server { + return nil +} + +func helper(x int) string { + return "" +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(2); + + expect(result.functions[0].name).toBe("NewServer"); + expect(result.functions[0].params).toEqual(["host", "port"]); + expect(result.functions[0].returnType).toBe("*Server"); + expect(result.functions[0].lineRange[0]).toBe(3); + + expect(result.functions[1].name).toBe("helper"); + expect(result.functions[1].params).toEqual(["x"]); + expect(result.functions[1].returnType).toBe("string"); + + tree.delete(); + parser.delete(); + }); + + it("extracts functions with no params and no return type", () => { + const { tree, parser, root } = parse(`package main + +func noop() { +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("noop"); + expect(result.functions[0].params).toEqual([]); + expect(result.functions[0].returnType).toBeUndefined(); + + tree.delete(); + parser.delete(); + }); + + it("extracts functions with multiple return types", () => { + const { tree, parser, root } = parse(`package main + +func divide(a, b float64) (float64, error) { + return 0, nil +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("divide"); + expect(result.functions[0].params).toEqual(["a", "b"]); + expect(result.functions[0].returnType).toBe("(float64, error)"); + + tree.delete(); + parser.delete(); + }); + + it("reports correct line ranges for multi-line functions", () => { + const { tree, parser, root } = parse(`package main + +func multiline( + a int, + b int, +) int { + result := a + b + return result +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].lineRange[0]).toBe(3); + expect(result.functions[0].lineRange[1]).toBe(9); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Methods ---- + + describe("extractStructure - methods", () => { + it("extracts methods with receivers", () => { + const { tree, parser, root } = parse(`package main + +type Server struct { + Host string +} + +func (s *Server) Start() error { + return nil +} + +func (s Server) Name() string { + return s.Host +} +`); + const result = extractor.extractStructure(root); + + // Methods appear in functions list + const methodNames = result.functions.map((f) => f.name); + expect(methodNames).toContain("Start"); + expect(methodNames).toContain("Name"); + + // Methods are also linked to the struct + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Server"); + expect(result.classes[0].methods).toContain("Start"); + expect(result.classes[0].methods).toContain("Name"); + + // Method return types are extracted + const startFn = result.functions.find((f) => f.name === "Start"); + expect(startFn?.returnType).toBe("error"); + expect(startFn?.params).toEqual([]); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Structs ---- + + describe("extractStructure - structs", () => { + it("extracts struct with fields", () => { + const { tree, parser, root } = parse(`package main + +type Server struct { + Host string + Port int +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Server"); + expect(result.classes[0].properties).toEqual(["Host", "Port"]); + expect(result.classes[0].methods).toEqual([]); + expect(result.classes[0].lineRange[0]).toBe(3); + + tree.delete(); + parser.delete(); + }); + + it("extracts empty struct", () => { + const { tree, parser, root } = parse(`package main + +type Empty struct{} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Empty"); + expect(result.classes[0].properties).toEqual([]); + expect(result.classes[0].methods).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("extracts struct with multiple name fields sharing a type", () => { + const { tree, parser, root } = parse(`package main + +type Point struct { + X, Y int + Z float64 +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].properties).toContain("X"); + expect(result.classes[0].properties).toContain("Y"); + expect(result.classes[0].properties).toContain("Z"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Interfaces ---- + + describe("extractStructure - interfaces", () => { + it("extracts interface with method signatures", () => { + const { tree, parser, root } = parse(`package main + +type Reader interface { + Read(buf []byte) (int, error) + Close() error +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Reader"); + expect(result.classes[0].methods).toEqual(["Read", "Close"]); + expect(result.classes[0].properties).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("extracts empty interface", () => { + const { tree, parser, root } = parse(`package main + +type Any interface{} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Any"); + expect(result.classes[0].methods).toEqual([]); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Imports ---- + + describe("extractStructure - imports", () => { + it("extracts grouped imports", () => { + const { tree, parser, root } = parse(`package main + +import ( + "fmt" + "os" +) +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("fmt"); + expect(result.imports[0].specifiers).toEqual(["fmt"]); + expect(result.imports[1].source).toBe("os"); + expect(result.imports[1].specifiers).toEqual(["os"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts single import", () => { + const { tree, parser, root } = parse(`package main + +import "fmt" +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("fmt"); + expect(result.imports[0].specifiers).toEqual(["fmt"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts imports with path components", () => { + const { tree, parser, root } = parse(`package main + +import "net/http" +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("net/http"); + expect(result.imports[0].specifiers).toEqual(["http"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts aliased imports", () => { + const { tree, parser, root } = parse(`package main + +import ( + f "fmt" + myhttp "net/http" +) +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("fmt"); + expect(result.imports[0].specifiers).toEqual(["f"]); + expect(result.imports[1].source).toBe("net/http"); + expect(result.imports[1].specifiers).toEqual(["myhttp"]); + + tree.delete(); + parser.delete(); + }); + + it("reports correct import line numbers", () => { + const { tree, parser, root } = parse(`package main + +import ( + "fmt" + "os" +) +`); + const result = extractor.extractStructure(root); + + expect(result.imports[0].lineNumber).toBe(4); + expect(result.imports[1].lineNumber).toBe(5); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Exports ---- + + describe("extractStructure - exports", () => { + it("exports uppercase function and type names", () => { + const { tree, parser, root } = parse(`package main + +type Server struct { + Host string + Port int +} + +func (s *Server) Start() error { + return nil +} + +func NewServer(host string, port int) *Server { + return nil +} + +func helper(x int) string { + return "" +} +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Server"); + expect(exportNames).toContain("Start"); + expect(exportNames).toContain("NewServer"); + expect(exportNames).not.toContain("helper"); + expect(result.exports).toHaveLength(3); + + tree.delete(); + parser.delete(); + }); + + it("does not export lowercase names", () => { + const { tree, parser, root } = parse(`package main + +type internal struct { + value int +} + +func private() {} +`); + const result = extractor.extractStructure(root); + + expect(result.exports).toHaveLength(0); + + tree.delete(); + parser.delete(); + }); + + it("exports uppercase interface names", () => { + const { tree, parser, root } = parse(`package main + +type Writer interface { + Write(data []byte) error +} + +type reader interface { + read() error +} +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Writer"); + expect(exportNames).not.toContain("reader"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Call Graph ---- + + describe("extractCallGraph", () => { + it("extracts simple function calls", () => { + const { tree, parser, root } = parse(`package main + +func process(data int) { + transform(data) + formatOutput(data) +} + +func main() { + process(42) +} +`); + const result = extractor.extractCallGraph(root); + + const processCalls = result.filter((e) => e.caller === "process"); + expect(processCalls.some((e) => e.callee === "transform")).toBe(true); + expect(processCalls.some((e) => e.callee === "formatOutput")).toBe(true); + + const mainCalls = result.filter((e) => e.caller === "main"); + expect(mainCalls.some((e) => e.callee === "process")).toBe(true); + + tree.delete(); + parser.delete(); + }); + + it("extracts selector expression calls (e.g. fmt.Println)", () => { + const { tree, parser, root } = parse(`package main + +import "fmt" + +func Start() { + fmt.Println("starting") +} + +func helper(x int) string { + return fmt.Sprintf("%d", x) +} +`); + const result = extractor.extractCallGraph(root); + + const startCalls = result.filter((e) => e.caller === "Start"); + expect(startCalls.some((e) => e.callee === "fmt.Println")).toBe(true); + + const helperCalls = result.filter((e) => e.caller === "helper"); + expect(helperCalls.some((e) => e.callee === "fmt.Sprintf")).toBe(true); + + tree.delete(); + parser.delete(); + }); + + it("tracks correct caller for methods", () => { + const { tree, parser, root } = parse(`package main + +import "fmt" + +func (s *Server) Start() error { + fmt.Println("starting") + return nil +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(1); + expect(result[0].caller).toBe("Start"); + expect(result[0].callee).toBe("fmt.Println"); + + tree.delete(); + parser.delete(); + }); + + it("reports correct line numbers for calls", () => { + const { tree, parser, root } = parse(`package main + +func main() { + foo() + bar() +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(2); + expect(result[0].lineNumber).toBe(4); + expect(result[1].lineNumber).toBe(5); + + tree.delete(); + parser.delete(); + }); + + it("ignores top-level calls (no caller)", () => { + const { tree, parser, root } = parse(`package main + +var _ = fmt.Println("hello") +`); + const result = extractor.extractCallGraph(root); + + // Top-level calls have no enclosing function, so they are skipped + expect(result).toHaveLength(0); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Comprehensive ---- + + describe("comprehensive Go file", () => { + it("handles a realistic Go module", () => { + const { tree, parser, root } = parse(`package main + +import ( + "fmt" + "os" +) + +type Server struct { + Host string + Port int +} + +func (s *Server) Start() error { + fmt.Println("starting") + return nil +} + +func NewServer(host string, port int) *Server { + return &Server{Host: host, Port: port} +} + +func helper(x int) string { + return fmt.Sprintf("%d", x) +} +`); + const result = extractor.extractStructure(root); + + // Functions: Start, NewServer, helper + expect(result.functions).toHaveLength(3); + expect(result.functions.map((f) => f.name).sort()).toEqual( + ["Start", "NewServer", "helper"].sort(), + ); + + // Struct: Server with properties Host, Port and method Start + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Server"); + expect(result.classes[0].properties).toEqual(["Host", "Port"]); + expect(result.classes[0].methods).toContain("Start"); + + // Imports: fmt, os + expect(result.imports).toHaveLength(2); + expect(result.imports.map((i) => i.source).sort()).toEqual(["fmt", "os"]); + + // Exports: Server, Start, NewServer (all uppercase) + const exportNames = result.exports.map((e) => e.name).sort(); + expect(exportNames).toEqual(["NewServer", "Server", "Start"]); + + // Call graph + const calls = extractor.extractCallGraph(root); + const startCalls = calls.filter((e) => e.caller === "Start"); + expect(startCalls.some((e) => e.callee === "fmt.Println")).toBe(true); + + const helperCalls = calls.filter((e) => e.caller === "helper"); + expect(helperCalls.some((e) => e.callee === "fmt.Sprintf")).toBe(true); + + tree.delete(); + parser.delete(); + }); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts new file mode 100644 index 0000000..53e3e95 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts @@ -0,0 +1,402 @@ +import type { StructuralAnalysis, CallGraphEntry } from "../../types.js"; +import type { LanguageExtractor, TreeSitterNode } from "./types.js"; +import { findChild, findChildren } from "./base-extractor.js"; + +/** + * Extract parameter names from a Go `parameter_list` node. + * + * Each child `parameter_declaration` has one or more `identifier` name children + * followed by a type node. Go allows unnamed params (e.g. in interface method + * signatures), which we skip since they have no user-visible name. + */ +function extractParams(paramsNode: TreeSitterNode | null): string[] { + if (!paramsNode) return []; + const params: string[] = []; + + const declarations = findChildren(paramsNode, "parameter_declaration"); + for (const decl of declarations) { + // A parameter_declaration can have multiple name identifiers sharing + // a type, e.g. `a, b int`. Collect all identifiers. + for (let i = 0; i < decl.childCount; i++) { + const child = decl.child(i); + if (child && child.type === "identifier") { + params.push(child.text); + } + } + } + + return params; +} + +/** + * Extract the return type text from a function/method declaration's `result` field. + * + * Go supports three forms: + * - single type: `error` -> "error" + * - pointer type: `*Server` -> "*Server" + * - multiple returns via parameter_list: `(string, error)` -> "(string, error)" + */ +function extractResultType(node: TreeSitterNode): string | undefined { + const result = node.childForFieldName("result"); + if (!result) return undefined; + return result.text; +} + +/** + * Extract the receiver type name from a method_declaration's receiver parameter_list. + * Returns the base type name (without pointer star), e.g. `(s *Server)` -> "Server". + */ +function extractReceiverType(receiverNode: TreeSitterNode): string | undefined { + const decl = findChild(receiverNode, "parameter_declaration"); + if (!decl) return undefined; + + // Look for type_identifier directly or inside pointer_type + for (let i = 0; i < decl.childCount; i++) { + const child = decl.child(i); + if (!child) continue; + if (child.type === "type_identifier") { + return child.text; + } + if (child.type === "pointer_type") { + const typeId = findChild(child, "type_identifier"); + if (typeId) return typeId.text; + } + } + return undefined; +} + +/** + * Check if a name is exported in Go (starts with an uppercase letter). + */ +function isExported(name: string): boolean { + if (name.length === 0) return false; + const first = name.charCodeAt(0); + return first >= 65 && first <= 90; // A-Z +} + +/** + * Go extractor for tree-sitter structural analysis and call graph extraction. + * + * Handles functions, methods, structs, interfaces, imports, exports, and + * call graphs for Go source code. + * + * Go-specific mapping decisions: + * - Structs and interfaces are mapped to the `classes` array. + * - Methods (with receivers) are stored as functions and also listed + * in the corresponding struct's `methods` array. + * - Exports are determined by Go's capitalization convention. + */ +export class GoExtractor implements LanguageExtractor { + readonly languageIds = ["go"]; + + extractStructure(rootNode: TreeSitterNode): StructuralAnalysis { + const functions: StructuralAnalysis["functions"] = []; + const classes: StructuralAnalysis["classes"] = []; + const imports: StructuralAnalysis["imports"] = []; + const exports: StructuralAnalysis["exports"] = []; + + // Track methods per receiver type so we can attach them to structs + const methodsByReceiver = new Map(); + + for (let i = 0; i < rootNode.childCount; i++) { + const node = rootNode.child(i); + if (!node) continue; + + switch (node.type) { + case "function_declaration": + this.extractFunction(node, functions, exports); + break; + + case "method_declaration": + this.extractMethod(node, functions, exports, methodsByReceiver); + break; + + case "type_declaration": + this.extractTypeDeclaration(node, classes, exports); + break; + + case "import_declaration": + this.extractImportDeclaration(node, imports); + break; + } + } + + // Attach collected methods to their receiver structs/interfaces + for (const cls of classes) { + const methods = methodsByReceiver.get(cls.name); + if (methods) { + cls.methods.push(...methods); + } + } + + return { functions, classes, imports, exports }; + } + + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] { + const entries: CallGraphEntry[] = []; + const functionStack: string[] = []; + + const walkForCalls = (node: TreeSitterNode) => { + let pushedName = false; + + // Track entering function/method declarations + if (node.type === "function_declaration") { + const nameNode = node.childForFieldName("name"); + if (nameNode) { + functionStack.push(nameNode.text); + pushedName = true; + } + } else if (node.type === "method_declaration") { + const nameNode = node.childForFieldName("name"); + if (nameNode) { + functionStack.push(nameNode.text); + pushedName = true; + } + } + + // Extract call expressions + if (node.type === "call_expression") { + const calleeNode = node.childForFieldName("function"); + if (calleeNode && functionStack.length > 0) { + entries.push({ + caller: functionStack[functionStack.length - 1], + callee: calleeNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) walkForCalls(child); + } + + if (pushedName) { + functionStack.pop(); + } + }; + + walkForCalls(rootNode); + + return entries; + } + + // ---- Private helpers ---- + + private extractFunction( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const paramsNode = node.childForFieldName("parameters"); + const params = extractParams(paramsNode ?? null); + const returnType = extractResultType(node); + + functions.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + params, + returnType, + }); + + if (isExported(nameNode.text)) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractMethod( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + methodsByReceiver: Map, + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const paramsNode = node.childForFieldName("parameters"); + const params = extractParams(paramsNode ?? null); + const returnType = extractResultType(node); + + functions.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + params, + returnType, + }); + + // Track receiver type for struct association + const receiverNode = node.childForFieldName("receiver"); + if (receiverNode) { + const receiverType = extractReceiverType(receiverNode); + if (receiverType) { + if (!methodsByReceiver.has(receiverType)) { + methodsByReceiver.set(receiverType, []); + } + methodsByReceiver.get(receiverType)!.push(nameNode.text); + } + } + + if (isExported(nameNode.text)) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractTypeDeclaration( + node: TreeSitterNode, + classes: StructuralAnalysis["classes"], + exports: StructuralAnalysis["exports"], + ): void { + const typeSpec = findChild(node, "type_spec"); + if (!typeSpec) return; + + const nameNode = typeSpec.childForFieldName("name"); + const typeNode = typeSpec.childForFieldName("type"); + if (!nameNode || !typeNode) return; + + if (typeNode.type === "struct_type") { + this.extractStruct(node, nameNode, typeNode, classes, exports); + } else if (typeNode.type === "interface_type") { + this.extractInterface(node, nameNode, typeNode, classes, exports); + } + } + + private extractStruct( + declNode: TreeSitterNode, + nameNode: TreeSitterNode, + structNode: TreeSitterNode, + classes: StructuralAnalysis["classes"], + exports: StructuralAnalysis["exports"], + ): void { + const properties: string[] = []; + + const fieldList = findChild(structNode, "field_declaration_list"); + if (fieldList) { + const fields = findChildren(fieldList, "field_declaration"); + for (const field of fields) { + // A field_declaration can have multiple names: `X, Y int` + for (let i = 0; i < field.childCount; i++) { + const child = field.child(i); + if (child && child.type === "field_identifier") { + properties.push(child.text); + } + } + } + } + + classes.push({ + name: nameNode.text, + lineRange: [ + declNode.startPosition.row + 1, + declNode.endPosition.row + 1, + ], + methods: [], // Methods are attached later from methodsByReceiver + properties, + }); + + if (isExported(nameNode.text)) { + exports.push({ + name: nameNode.text, + lineNumber: declNode.startPosition.row + 1, + }); + } + } + + private extractInterface( + declNode: TreeSitterNode, + nameNode: TreeSitterNode, + interfaceNode: TreeSitterNode, + classes: StructuralAnalysis["classes"], + exports: StructuralAnalysis["exports"], + ): void { + const methods: string[] = []; + + const methodElems = findChildren(interfaceNode, "method_elem"); + for (const elem of methodElems) { + const methName = elem.childForFieldName("name"); + if (methName) { + methods.push(methName.text); + } + } + + classes.push({ + name: nameNode.text, + lineRange: [ + declNode.startPosition.row + 1, + declNode.endPosition.row + 1, + ], + methods, + properties: [], // Interfaces have no properties + }); + + if (isExported(nameNode.text)) { + exports.push({ + name: nameNode.text, + lineNumber: declNode.startPosition.row + 1, + }); + } + } + + private extractImportDeclaration( + node: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + // Grouped imports: import ( ... ) + const specList = findChild(node, "import_spec_list"); + if (specList) { + const specs = findChildren(specList, "import_spec"); + for (const spec of specs) { + this.extractImportSpec(spec, imports); + } + } else { + // Single import: import "fmt" + const spec = findChild(node, "import_spec"); + if (spec) { + this.extractImportSpec(spec, imports); + } + } + } + + private extractImportSpec( + spec: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + const pathNode = spec.childForFieldName("path"); + if (!pathNode) return; + + // Extract unquoted path + const pathContent = findChild(pathNode, "interpreted_string_literal_content"); + const source = pathContent ? pathContent.text : pathNode.text.replace(/^"|"$/g, ""); + + // Determine the specifier: alias if present, otherwise last path component + const nameNode = spec.childForFieldName("name"); + let specifier: string; + if (nameNode) { + specifier = nameNode.text; + } else { + // Use last path component, e.g. "net/http" -> "http" + const parts = source.split("/"); + specifier = parts[parts.length - 1]; + } + + imports.push({ + source, + specifiers: [specifier], + lineNumber: spec.startPosition.row + 1, + }); + } +} From a1fb9585d34c9ff60dae057468da58fa14be4554 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 18:38:26 +0800 Subject: [PATCH 09/18] feat: add RustExtractor for tree-sitter Rust structural analysis Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/rust-extractor.test.ts | 755 ++++++++++++++++++ .../src/plugins/extractors/rust-extractor.ts | 509 ++++++++++++ 2 files changed, 1264 insertions(+) create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/rust-extractor.test.ts create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/rust-extractor.ts diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/rust-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/rust-extractor.test.ts new file mode 100644 index 0000000..0687960 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/rust-extractor.test.ts @@ -0,0 +1,755 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { createRequire } from "node:module"; +import { RustExtractor } from "../rust-extractor.js"; + +const require = createRequire(import.meta.url); + +// Load tree-sitter + Rust grammar once +let Parser: any; +let Language: any; +let rustLang: any; + +beforeAll(async () => { + const mod = await import("web-tree-sitter"); + Parser = mod.Parser; + Language = mod.Language; + await Parser.init(); + const wasmPath = require.resolve( + "tree-sitter-rust/tree-sitter-rust.wasm", + ); + rustLang = await Language.load(wasmPath); +}); + +function parse(code: string) { + const parser = new Parser(); + parser.setLanguage(rustLang); + const tree = parser.parse(code); + const root = tree.rootNode; + return { tree, parser, root }; +} + +describe("RustExtractor", () => { + const extractor = new RustExtractor(); + + it("has correct languageIds", () => { + expect(extractor.languageIds).toEqual(["rust"]); + }); + + // ---- Functions ---- + + describe("extractStructure - functions", () => { + it("extracts top-level functions with params and return types", () => { + const { tree, parser, root } = parse(` +pub fn check_port(port: u16) -> bool { + port > 0 +} + +fn helper(name: String, count: i32) -> String { + name.repeat(count) +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(2); + + expect(result.functions[0].name).toBe("check_port"); + expect(result.functions[0].params).toEqual(["port"]); + expect(result.functions[0].returnType).toBe("bool"); + + expect(result.functions[1].name).toBe("helper"); + expect(result.functions[1].params).toEqual(["name", "count"]); + expect(result.functions[1].returnType).toBe("String"); + + tree.delete(); + parser.delete(); + }); + + it("extracts functions with no params and no return type", () => { + const { tree, parser, root } = parse(` +fn noop() { +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("noop"); + expect(result.functions[0].params).toEqual([]); + expect(result.functions[0].returnType).toBeUndefined(); + + tree.delete(); + parser.delete(); + }); + + it("reports correct line ranges for multi-line functions", () => { + const { tree, parser, root } = parse(` +fn multiline( + a: i32, + b: i32, +) -> i32 { + let result = a + b; + result +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].lineRange[0]).toBe(2); + expect(result.functions[0].lineRange[1]).toBe(8); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Methods (impl blocks) ---- + + describe("extractStructure - impl blocks and methods", () => { + it("extracts methods from impl blocks and links them to structs", () => { + const { tree, parser, root } = parse(` +pub struct Config { + name: String, + port: u16, +} + +impl Config { + pub fn new(name: String, port: u16) -> Self { + Config { name, port } + } + + fn validate(&self) -> bool { + true + } +} +`); + const result = extractor.extractStructure(root); + + // Methods appear in functions list + const fnNames = result.functions.map((f) => f.name); + expect(fnNames).toContain("new"); + expect(fnNames).toContain("validate"); + + // new() skips self parameter, captures real params + const newFn = result.functions.find((f) => f.name === "new"); + expect(newFn?.params).toEqual(["name", "port"]); + expect(newFn?.returnType).toBe("Self"); + + // validate() has &self — should have no params + const validateFn = result.functions.find((f) => f.name === "validate"); + expect(validateFn?.params).toEqual([]); + expect(validateFn?.returnType).toBe("bool"); + + // Methods linked to struct + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Config"); + expect(result.classes[0].methods).toContain("new"); + expect(result.classes[0].methods).toContain("validate"); + + tree.delete(); + parser.delete(); + }); + + it("handles impl blocks for enums", () => { + const { tree, parser, root } = parse(` +enum Status { + Active, + Inactive, +} + +impl Status { + fn is_active(&self) -> bool { + true + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Status"); + expect(result.classes[0].methods).toContain("is_active"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Structs ---- + + describe("extractStructure - structs", () => { + it("extracts struct with fields", () => { + const { tree, parser, root } = parse(` +pub struct Config { + name: String, + port: u16, +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Config"); + expect(result.classes[0].properties).toEqual(["name", "port"]); + expect(result.classes[0].methods).toEqual([]); + expect(result.classes[0].lineRange[0]).toBe(2); + + tree.delete(); + parser.delete(); + }); + + it("extracts empty struct (unit struct)", () => { + const { tree, parser, root } = parse(` +struct Empty; +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Empty"); + expect(result.classes[0].properties).toEqual([]); + expect(result.classes[0].methods).toEqual([]); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Enums ---- + + describe("extractStructure - enums", () => { + it("extracts enum with variants as properties", () => { + const { tree, parser, root } = parse(` +enum Status { + Active, + Inactive, +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Status"); + expect(result.classes[0].properties).toEqual(["Active", "Inactive"]); + expect(result.classes[0].methods).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("extracts pub enum", () => { + const { tree, parser, root } = parse(` +pub enum Direction { + North, + South, + East, + West, +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Direction"); + expect(result.classes[0].properties).toEqual(["North", "South", "East", "West"]); + + // pub enum should be exported + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Direction"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Traits ---- + + describe("extractStructure - traits", () => { + it("extracts trait with method signatures", () => { + const { tree, parser, root } = parse(` +pub trait Validator { + fn validate(&self) -> bool; + fn name(&self) -> &str; +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Validator"); + expect(result.classes[0].methods).toEqual(["validate", "name"]); + expect(result.classes[0].properties).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("extracts empty trait", () => { + const { tree, parser, root } = parse(` +trait Marker {} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Marker"); + expect(result.classes[0].methods).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("exports pub traits", () => { + const { tree, parser, root } = parse(` +pub trait Serializable { + fn serialize(&self) -> String; +} + +trait Internal { + fn process(&self); +} +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Serializable"); + expect(exportNames).not.toContain("Internal"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Imports ---- + + describe("extractStructure - imports", () => { + it("extracts scoped identifier imports", () => { + const { tree, parser, root } = parse(` +use std::collections::HashMap; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("std::collections"); + expect(result.imports[0].specifiers).toEqual(["HashMap"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts scoped use list imports", () => { + const { tree, parser, root } = parse(` +use std::io::{self, Read, Write}; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("std::io"); + expect(result.imports[0].specifiers).toEqual(["self", "Read", "Write"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts wildcard imports", () => { + const { tree, parser, root } = parse(` +use std::prelude::*; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("std::prelude"); + expect(result.imports[0].specifiers).toEqual(["*"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts simple identifier imports", () => { + const { tree, parser, root } = parse(` +use foo; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("foo"); + expect(result.imports[0].specifiers).toEqual(["foo"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts crate-relative imports", () => { + const { tree, parser, root } = parse(` +use crate::config::Settings; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("crate::config"); + expect(result.imports[0].specifiers).toEqual(["Settings"]); + + tree.delete(); + parser.delete(); + }); + + it("reports correct import line numbers", () => { + const { tree, parser, root } = parse(` +use std::collections::HashMap; +use std::io::{self, Read}; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(2); + expect(result.imports[0].lineNumber).toBe(2); + expect(result.imports[1].lineNumber).toBe(3); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Exports ---- + + describe("extractStructure - exports", () => { + it("exports pub items and not private items", () => { + const { tree, parser, root } = parse(` +pub struct Config { + name: String, +} + +struct Internal { + value: i32, +} + +pub fn check_port(port: u16) -> bool { + port > 0 +} + +fn helper() {} +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Config"); + expect(exportNames).toContain("check_port"); + expect(exportNames).not.toContain("Internal"); + expect(exportNames).not.toContain("helper"); + + tree.delete(); + parser.delete(); + }); + + it("exports pub methods inside impl blocks", () => { + const { tree, parser, root } = parse(` +pub struct Config { + name: String, +} + +impl Config { + pub fn new(name: String) -> Self { + Config { name } + } + + fn validate(&self) -> bool { + true + } +} +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Config"); + expect(exportNames).toContain("new"); + expect(exportNames).not.toContain("validate"); + + tree.delete(); + parser.delete(); + }); + + it("reports correct export line numbers", () => { + const { tree, parser, root } = parse(` +pub struct Config { + name: String, +} + +pub fn check_port(port: u16) -> bool { + port > 0 +} +`); + const result = extractor.extractStructure(root); + + const configExport = result.exports.find((e) => e.name === "Config"); + expect(configExport?.lineNumber).toBe(2); + + const checkPortExport = result.exports.find((e) => e.name === "check_port"); + expect(checkPortExport?.lineNumber).toBe(6); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Call Graph ---- + + describe("extractCallGraph", () => { + it("extracts simple function calls", () => { + const { tree, parser, root } = parse(` +fn validate(port: u16) -> bool { + check_port(port) +} + +fn main() { + validate(8080); +} +`); + const result = extractor.extractCallGraph(root); + + const validateCalls = result.filter((e) => e.caller === "validate"); + expect(validateCalls.some((e) => e.callee === "check_port")).toBe(true); + + const mainCalls = result.filter((e) => e.caller === "main"); + expect(mainCalls.some((e) => e.callee === "validate")).toBe(true); + + tree.delete(); + parser.delete(); + }); + + it("extracts method calls (field_expression)", () => { + const { tree, parser, root } = parse(` +fn process(&self) { + self.validate(); +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(1); + expect(result[0].caller).toBe("process"); + expect(result[0].callee).toBe("self.validate"); + + tree.delete(); + parser.delete(); + }); + + it("extracts scoped calls (e.g., Vec::new)", () => { + const { tree, parser, root } = parse(` +fn create() { + Vec::new(); +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(1); + expect(result[0].caller).toBe("create"); + expect(result[0].callee).toBe("Vec::new"); + + tree.delete(); + parser.delete(); + }); + + it("tracks correct caller for methods in impl blocks", () => { + const { tree, parser, root } = parse(` +impl Config { + fn validate(&self) -> bool { + check_port(self.port) + } +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(1); + expect(result[0].caller).toBe("validate"); + expect(result[0].callee).toBe("check_port"); + + tree.delete(); + parser.delete(); + }); + + it("reports correct line numbers for calls", () => { + const { tree, parser, root } = parse(` +fn main() { + foo(); + bar(); +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(2); + expect(result[0].lineNumber).toBe(3); + expect(result[1].lineNumber).toBe(4); + + tree.delete(); + parser.delete(); + }); + + it("ignores top-level calls (no caller)", () => { + const { tree, parser, root } = parse(` +static X: i32 = compute(); +`); + const result = extractor.extractCallGraph(root); + + // Top-level calls have no enclosing function, so they are skipped + expect(result).toHaveLength(0); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Comprehensive ---- + + describe("comprehensive Rust file", () => { + it("handles the full test scenario from the spec", () => { + const { tree, parser, root } = parse(`use std::collections::HashMap; +use std::io::{self, Read}; + +pub struct Config { + name: String, + port: u16, +} + +impl Config { + pub fn new(name: String, port: u16) -> Self { + Config { name, port } + } + + fn validate(&self) -> bool { + check_port(self.port) + } +} + +pub fn check_port(port: u16) -> bool { + port > 0 +} + +enum Status { + Active, + Inactive, +} +`); + const result = extractor.extractStructure(root); + + // Functions: new, validate, check_port + expect(result.functions).toHaveLength(3); + const fnNames = result.functions.map((f) => f.name).sort(); + expect(fnNames).toEqual(["check_port", "new", "validate"]); + + // new() params + const newFn = result.functions.find((f) => f.name === "new"); + expect(newFn?.params).toEqual(["name", "port"]); + expect(newFn?.returnType).toBe("Self"); + + // validate() has &self — no visible params + const validateFn = result.functions.find((f) => f.name === "validate"); + expect(validateFn?.params).toEqual([]); + expect(validateFn?.returnType).toBe("bool"); + + // check_port() + const checkPortFn = result.functions.find((f) => f.name === "check_port"); + expect(checkPortFn?.params).toEqual(["port"]); + expect(checkPortFn?.returnType).toBe("bool"); + + // Classes: Config (struct) and Status (enum) + expect(result.classes).toHaveLength(2); + + const configClass = result.classes.find((c) => c.name === "Config"); + expect(configClass).toBeDefined(); + expect(configClass!.properties).toEqual(["name", "port"]); + expect(configClass!.methods).toContain("new"); + expect(configClass!.methods).toContain("validate"); + + const statusClass = result.classes.find((c) => c.name === "Status"); + expect(statusClass).toBeDefined(); + expect(statusClass!.properties).toEqual(["Active", "Inactive"]); + expect(statusClass!.methods).toEqual([]); + + // Imports: 2 + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("std::collections"); + expect(result.imports[0].specifiers).toEqual(["HashMap"]); + expect(result.imports[1].source).toBe("std::io"); + expect(result.imports[1].specifiers).toEqual(["self", "Read"]); + + // Exports: Config, new, check_port (those with pub) + const exportNames = result.exports.map((e) => e.name).sort(); + expect(exportNames).toEqual(["Config", "check_port", "new"]); + + // Call graph: validate -> check_port + const calls = extractor.extractCallGraph(root); + const validateCalls = calls.filter((e) => e.caller === "validate"); + expect(validateCalls).toHaveLength(1); + expect(validateCalls[0].callee).toBe("check_port"); + + tree.delete(); + parser.delete(); + }); + + it("handles a realistic Rust module with traits and multiple impls", () => { + const { tree, parser, root } = parse(`use std::fmt; +use std::io::{self, Write}; + +pub trait Displayable { + fn display(&self) -> String; +} + +pub struct Server { + host: String, + port: u16, +} + +impl Server { + pub fn new(host: String, port: u16) -> Self { + Server { host, port } + } + + pub fn start(&self) { + listen(self.port); + } +} + +impl Displayable for Server { + fn display(&self) -> String { + format_server(self.host.clone(), self.port) + } +} + +fn listen(port: u16) { + println!("Listening on port {}", port); +} + +fn format_server(host: String, port: u16) -> String { + format!("{}:{}", host, port) +} +`); + const result = extractor.extractStructure(root); + + // Functions: new, start, display, listen, format_server + expect(result.functions).toHaveLength(5); + + // Trait: Displayable + const trait_ = result.classes.find((c) => c.name === "Displayable"); + expect(trait_).toBeDefined(); + expect(trait_!.methods).toEqual(["display"]); + + // Struct: Server + const server = result.classes.find((c) => c.name === "Server"); + expect(server).toBeDefined(); + expect(server!.properties).toEqual(["host", "port"]); + // Methods from both impl blocks (Server and Displayable for Server) + expect(server!.methods).toContain("new"); + expect(server!.methods).toContain("start"); + expect(server!.methods).toContain("display"); + + // Exports: pub items + const exportNames = result.exports.map((e) => e.name).sort(); + expect(exportNames).toContain("Displayable"); + expect(exportNames).toContain("Server"); + expect(exportNames).toContain("new"); + expect(exportNames).toContain("start"); + expect(exportNames).not.toContain("listen"); + expect(exportNames).not.toContain("format_server"); + + // Call graph + const calls = extractor.extractCallGraph(root); + const startCalls = calls.filter((e) => e.caller === "start"); + expect(startCalls.some((e) => e.callee === "listen")).toBe(true); + + const displayCalls = calls.filter((e) => e.caller === "display"); + expect(displayCalls.some((e) => e.callee === "format_server")).toBe(true); + + tree.delete(); + parser.delete(); + }); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/rust-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/rust-extractor.ts new file mode 100644 index 0000000..98ab38f --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/rust-extractor.ts @@ -0,0 +1,509 @@ +import type { StructuralAnalysis, CallGraphEntry } from "../../types.js"; +import type { LanguageExtractor, TreeSitterNode } from "./types.js"; +import { findChild, findChildren } from "./base-extractor.js"; + +/** + * Extract parameter names from a Rust `parameters` node. + * + * Each `parameter` child has a `pattern` field (identifier) and a `type` field. + * `self_parameter` nodes (&self, &mut self, self) are skipped since they are + * implicit receivers, not user-facing parameters. + */ +function extractParams(paramsNode: TreeSitterNode | null): string[] { + if (!paramsNode) return []; + const params: string[] = []; + + for (let i = 0; i < paramsNode.childCount; i++) { + const child = paramsNode.child(i); + if (!child) continue; + + if (child.type === "parameter") { + const pattern = child.childForFieldName("pattern"); + if (pattern) { + params.push(pattern.text); + } + } + // Skip self_parameter — it's the receiver, not a real parameter + } + + return params; +} + +/** + * Extract the return type from a function_item node. + * + * In tree-sitter-rust, the return type is accessed via the `return_type` named + * field on function_item. The field value is the type node itself (e.g. + * primitive_type "bool", type_identifier "Self"). + */ +function extractReturnType(node: TreeSitterNode): string | undefined { + const returnType = node.childForFieldName("return_type"); + if (returnType) { + return returnType.text; + } + return undefined; +} + +/** + * Check if a node has a `visibility_modifier` child whose text starts with "pub". + * Covers `pub`, `pub(crate)`, `pub(super)`, etc. + */ +function isPublic(node: TreeSitterNode): boolean { + const visMod = findChild(node, "visibility_modifier"); + return visMod !== null && visMod.text.startsWith("pub"); +} + +/** + * Recursively extract the path portion of a scoped_identifier. + * + * `scoped_identifier` nests: `std::collections::HashMap` is + * scoped_identifier(path: scoped_identifier(path: identifier "std", name: identifier "collections"), name: identifier "HashMap") + * + * This function collects all path segments into a flat "a::b::c" string, + * excluding the final `name` (which is the imported specifier). + */ +function extractScopedPath(node: TreeSitterNode): { path: string; name: string } { + if (node.type === "scoped_identifier") { + const pathNode = node.childForFieldName("path"); + const nameNode = node.childForFieldName("name"); + const name = nameNode ? nameNode.text : ""; + const path = pathNode ? pathNode.text : ""; + return { path, name }; + } + // Bare identifier: `use foo;` + return { path: "", name: node.text }; +} + +/** + * Rust extractor for tree-sitter structural analysis and call graph extraction. + * + * Handles functions, structs, enums, traits, impl blocks, use declarations, + * visibility-based exports, and call graphs for Rust source code. + * + * Rust-specific mapping decisions: + * - Structs, enums, and traits are mapped to the `classes` array. + * - Methods inside `impl` blocks are stored as functions and also listed + * in the corresponding struct/enum's `methods` array. + * - Trait method signatures (function_signature_item) are listed in the + * trait's `methods` array. + * - Exports are determined by the `pub` visibility modifier. + * - Enum variants are extracted as `properties` of the enum class entry. + */ +export class RustExtractor implements LanguageExtractor { + readonly languageIds = ["rust"]; + + extractStructure(rootNode: TreeSitterNode): StructuralAnalysis { + const functions: StructuralAnalysis["functions"] = []; + const classes: StructuralAnalysis["classes"] = []; + const imports: StructuralAnalysis["imports"] = []; + const exports: StructuralAnalysis["exports"] = []; + + // Track methods per impl type so we can attach them to structs/enums + const methodsByType = new Map(); + + for (let i = 0; i < rootNode.childCount; i++) { + const node = rootNode.child(i); + if (!node) continue; + + switch (node.type) { + case "function_item": + this.extractFunction(node, functions, exports); + break; + + case "struct_item": + this.extractStruct(node, classes, exports); + break; + + case "enum_item": + this.extractEnum(node, classes, exports); + break; + + case "trait_item": + this.extractTrait(node, classes, exports); + break; + + case "impl_item": + this.extractImpl(node, functions, exports, methodsByType); + break; + + case "use_declaration": + this.extractUseDeclaration(node, imports); + break; + } + } + + // Attach collected methods to their corresponding structs/enums/traits + for (const cls of classes) { + const methods = methodsByType.get(cls.name); + if (methods) { + cls.methods.push(...methods); + } + } + + return { functions, classes, imports, exports }; + } + + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] { + const entries: CallGraphEntry[] = []; + const functionStack: string[] = []; + + const walkForCalls = (node: TreeSitterNode) => { + let pushedName = false; + + // Track entering function_item declarations + if (node.type === "function_item") { + const nameNode = node.childForFieldName("name"); + if (nameNode) { + functionStack.push(nameNode.text); + pushedName = true; + } + } + + // Extract call expressions + if (node.type === "call_expression") { + if (functionStack.length > 0) { + const callee = this.extractCalleeName(node); + if (callee) { + entries.push({ + caller: functionStack[functionStack.length - 1], + callee, + lineNumber: node.startPosition.row + 1, + }); + } + } + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) walkForCalls(child); + } + + if (pushedName) { + functionStack.pop(); + } + }; + + walkForCalls(rootNode); + + return entries; + } + + // ---- Private helpers ---- + + /** + * Extract the callee name from a call_expression. + * + * Handles: + * - Plain function call: `check_port(x)` -> "check_port" + * - Method call via field_expression: `self.validate()` -> "self.validate" + * - Scoped call: `Vec::new()` -> "Vec::new" + */ + private extractCalleeName(callNode: TreeSitterNode): string | null { + const funcNode = callNode.child(0); + if (!funcNode) return null; + + if (funcNode.type === "identifier") { + return funcNode.text; + } + + if (funcNode.type === "field_expression") { + // e.g., self.validate or obj.method + const field = funcNode.childForFieldName("field"); + const value = funcNode.childForFieldName("value"); + if (field && value) { + return value.text + "." + field.text; + } + } + + if (funcNode.type === "scoped_identifier") { + // e.g., Vec::new + return funcNode.text; + } + + // Fallback: use the full text of the function child + return funcNode.text; + } + + private extractFunction( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const paramsNode = node.childForFieldName("parameters"); + const params = extractParams(paramsNode ?? null); + const returnType = extractReturnType(node); + + functions.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + params, + returnType, + }); + + if (isPublic(node)) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractStruct( + node: TreeSitterNode, + classes: StructuralAnalysis["classes"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const properties: string[] = []; + const body = node.childForFieldName("body"); + if (body && body.type === "field_declaration_list") { + const fields = findChildren(body, "field_declaration"); + for (const field of fields) { + const fieldName = findChild(field, "field_identifier"); + if (fieldName) { + properties.push(fieldName.text); + } + } + } + + classes.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + methods: [], // Methods are attached later from methodsByType + properties, + }); + + if (isPublic(node)) { + 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 properties: string[] = []; + const body = node.childForFieldName("body"); + if (body && body.type === "enum_variant_list") { + const variants = findChildren(body, "enum_variant"); + for (const variant of variants) { + const variantName = variant.childForFieldName("name"); + if (variantName) { + properties.push(variantName.text); + } + } + } + + classes.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + methods: [], // Methods are attached later if there's an impl block + properties, + }); + + if (isPublic(node)) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractTrait( + node: TreeSitterNode, + classes: StructuralAnalysis["classes"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const methods: string[] = []; + const body = findChild(node, "declaration_list"); + if (body) { + // Trait bodies contain function_signature_item for method declarations + const sigs = findChildren(body, "function_signature_item"); + for (const sig of sigs) { + const sigName = findChild(sig, "identifier"); + if (sigName) { + methods.push(sigName.text); + } + } + // Also handle default method implementations (function_item) + const fns = findChildren(body, "function_item"); + for (const fn of fns) { + const fnName = fn.childForFieldName("name"); + if (fnName) { + methods.push(fnName.text); + } + } + } + + classes.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + methods, + properties: [], + }); + + if (isPublic(node)) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractImpl( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + methodsByType: Map, + ): void { + const typeNode = node.childForFieldName("type"); + const typeName = typeNode ? typeNode.text : null; + + const body = node.childForFieldName("body"); + if (!body) return; + + const fns = findChildren(body, "function_item"); + for (const fn of fns) { + const nameNode = fn.childForFieldName("name"); + if (!nameNode) continue; + + const paramsNode = fn.childForFieldName("parameters"); + const params = extractParams(paramsNode ?? null); + const returnType = extractReturnType(fn); + + functions.push({ + name: nameNode.text, + lineRange: [ + fn.startPosition.row + 1, + fn.endPosition.row + 1, + ], + params, + returnType, + }); + + // Track method association with the impl type + if (typeName) { + if (!methodsByType.has(typeName)) { + methodsByType.set(typeName, []); + } + methodsByType.get(typeName)!.push(nameNode.text); + } + + // pub methods inside impl blocks are exports + if (isPublic(fn)) { + exports.push({ + name: nameNode.text, + lineNumber: fn.startPosition.row + 1, + }); + } + } + } + + private extractUseDeclaration( + node: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + const argument = node.childForFieldName("argument"); + if (!argument) return; + + switch (argument.type) { + case "identifier": + // `use foo;` + imports.push({ + source: argument.text, + specifiers: [argument.text], + lineNumber: node.startPosition.row + 1, + }); + break; + + case "scoped_identifier": { + // `use std::collections::HashMap;` + const { path, name } = extractScopedPath(argument); + imports.push({ + source: path, + specifiers: [name], + lineNumber: node.startPosition.row + 1, + }); + break; + } + + case "scoped_use_list": { + // `use std::io::{self, Read, Write};` + const pathNode = argument.childForFieldName("path"); + const listNode = argument.childForFieldName("list"); + const source = pathNode ? pathNode.text : ""; + const specifiers: string[] = []; + + if (listNode) { + for (let j = 0; j < listNode.childCount; j++) { + const ch = listNode.child(j); + if (!ch) continue; + if (ch.type === "self" || ch.type === "identifier") { + specifiers.push(ch.text); + } else if (ch.type === "scoped_identifier") { + // Nested scoped identifier inside a use list + specifiers.push(ch.text); + } + } + } + + imports.push({ + source, + specifiers, + lineNumber: node.startPosition.row + 1, + }); + break; + } + + case "use_wildcard": { + // `use std::prelude::*;` + // The path is the scoped_identifier child + const scopedId = findChild(argument, "scoped_identifier"); + const source = scopedId ? scopedId.text : ""; + imports.push({ + source, + specifiers: ["*"], + lineNumber: node.startPosition.row + 1, + }); + break; + } + + default: { + // Fallback for any unhandled pattern + imports.push({ + source: argument.text, + specifiers: [argument.text], + lineNumber: node.startPosition.row + 1, + }); + break; + } + } + } +} From 6e500cbfdf076aa5de37a75e31f79ff3f09799f0 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 18:42:55 +0800 Subject: [PATCH 10/18] feat: add JavaExtractor for tree-sitter Java structural analysis Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/java-extractor.test.ts | 568 ++++++++++++++++++ .../src/plugins/extractors/java-extractor.ts | 449 ++++++++++++++ 2 files changed, 1017 insertions(+) create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/java-extractor.test.ts create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/java-extractor.ts diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/java-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/java-extractor.test.ts new file mode 100644 index 0000000..17d14ad --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/java-extractor.test.ts @@ -0,0 +1,568 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { createRequire } from "node:module"; +import { JavaExtractor } from "../java-extractor.js"; + +const require = createRequire(import.meta.url); + +// Load tree-sitter + Java grammar once +let Parser: any; +let Language: any; +let javaLang: any; + +beforeAll(async () => { + const mod = await import("web-tree-sitter"); + Parser = mod.Parser; + Language = mod.Language; + await Parser.init(); + const wasmPath = require.resolve( + "tree-sitter-java/tree-sitter-java.wasm", + ); + javaLang = await Language.load(wasmPath); +}); + +function parse(code: string) { + const parser = new Parser(); + parser.setLanguage(javaLang); + const tree = parser.parse(code); + const root = tree.rootNode; + return { tree, parser, root }; +} + +describe("JavaExtractor", () => { + const extractor = new JavaExtractor(); + + it("has correct languageIds", () => { + expect(extractor.languageIds).toEqual(["java"]); + }); + + // ---- Methods/Constructors (mapped to functions) ---- + + describe("extractStructure - functions (methods & constructors)", () => { + it("extracts methods with params and return types", () => { + const { tree, parser, root } = parse(`public class Foo { + public String getName(int id) { + return ""; + } + private void process(String data, int count) { + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(2); + + expect(result.functions[0].name).toBe("getName"); + expect(result.functions[0].params).toEqual(["id"]); + expect(result.functions[0].returnType).toBe("String"); + + expect(result.functions[1].name).toBe("process"); + expect(result.functions[1].params).toEqual(["data", "count"]); + expect(result.functions[1].returnType).toBe("void"); + + tree.delete(); + parser.delete(); + }); + + it("extracts constructors", () => { + const { tree, parser, root } = parse(`public class Foo { + public Foo(String name, int value) { + this.name = name; + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("Foo"); + expect(result.functions[0].params).toEqual(["name", "value"]); + expect(result.functions[0].returnType).toBeUndefined(); + + tree.delete(); + parser.delete(); + }); + + it("extracts methods with no params", () => { + const { tree, parser, root } = parse(`public class Foo { + public void run() {} +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("run"); + expect(result.functions[0].params).toEqual([]); + expect(result.functions[0].returnType).toBe("void"); + + tree.delete(); + parser.delete(); + }); + + it("extracts methods with generic return types", () => { + const { tree, parser, root } = parse(`public class Foo { + public List getItems() { + return null; + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("getItems"); + expect(result.functions[0].returnType).toBe("List"); + + tree.delete(); + parser.delete(); + }); + + it("reports correct line ranges for multi-line methods", () => { + const { tree, parser, root } = parse(`public class Foo { + public int calculate( + int a, + int b + ) { + int result = a + b; + return result; + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].lineRange[0]).toBe(2); + expect(result.functions[0].lineRange[1]).toBe(8); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Classes ---- + + describe("extractStructure - classes", () => { + it("extracts class with methods and fields", () => { + const { tree, parser, root } = parse(`public class Server { + private String host; + private int port; + public void start() {} + public void stop() {} +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Server"); + expect(result.classes[0].properties).toEqual(["host", "port"]); + expect(result.classes[0].methods).toEqual(["start", "stop"]); + expect(result.classes[0].lineRange[0]).toBe(1); + + tree.delete(); + parser.delete(); + }); + + it("extracts empty class", () => { + const { tree, parser, root } = parse(`public class Empty { +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Empty"); + expect(result.classes[0].properties).toEqual([]); + expect(result.classes[0].methods).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("includes constructors in methods list", () => { + const { tree, parser, root } = parse(`public class Foo { + public Foo() {} + public void run() {} +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes[0].methods).toEqual(["Foo", "run"]); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Interfaces ---- + + describe("extractStructure - interfaces", () => { + it("extracts interface with method signatures", () => { + const { tree, parser, root } = parse(`interface Repository { + List findAll(); + User findById(int id); +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Repository"); + expect(result.classes[0].methods).toEqual(["findAll", "findById"]); + expect(result.classes[0].properties).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("extracts empty interface", () => { + const { tree, parser, root } = parse(`interface Marker { +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Marker"); + expect(result.classes[0].methods).toEqual([]); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Imports ---- + + describe("extractStructure - imports", () => { + it("extracts regular imports", () => { + const { tree, parser, root } = parse(`import java.util.List; +import java.util.Map; +public class Foo {} +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("java.util.List"); + expect(result.imports[0].specifiers).toEqual(["List"]); + expect(result.imports[0].lineNumber).toBe(1); + expect(result.imports[1].source).toBe("java.util.Map"); + expect(result.imports[1].specifiers).toEqual(["Map"]); + expect(result.imports[1].lineNumber).toBe(2); + + tree.delete(); + parser.delete(); + }); + + it("extracts wildcard imports", () => { + const { tree, parser, root } = parse(`import java.util.*; +public class Foo {} +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("java.util"); + expect(result.imports[0].specifiers).toEqual(["*"]); + + tree.delete(); + parser.delete(); + }); + + it("reports correct import line numbers", () => { + const { tree, parser, root } = parse(`import java.util.List; + +import java.util.Map; +public class Foo {} +`); + const result = extractor.extractStructure(root); + + expect(result.imports[0].lineNumber).toBe(1); + expect(result.imports[1].lineNumber).toBe(3); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Exports ---- + + describe("extractStructure - exports", () => { + it("exports public class, methods, and constructor", () => { + const { tree, parser, root } = parse(`public class UserService { + private String name; + public UserService(String name) { + this.name = name; + } + public void start() {} + private void helper() {} +} +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("UserService"); // class + // The constructor is also named UserService, check it's listed + const userServiceExports = result.exports.filter( + (e) => e.name === "UserService", + ); + expect(userServiceExports.length).toBe(2); // class + constructor + expect(exportNames).toContain("start"); + expect(exportNames).not.toContain("helper"); + expect(exportNames).not.toContain("name"); // private field + + tree.delete(); + parser.delete(); + }); + + it("does not export non-public classes", () => { + const { tree, parser, root } = parse(`class Internal { + void run() {} +} +`); + const result = extractor.extractStructure(root); + + expect(result.exports).toHaveLength(0); + + tree.delete(); + parser.delete(); + }); + + it("exports public fields", () => { + const { tree, parser, root } = parse(`public class Config { + public String apiKey; + private int retries; +} +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Config"); + expect(exportNames).toContain("apiKey"); + expect(exportNames).not.toContain("retries"); + + tree.delete(); + parser.delete(); + }); + + it("exports public interface", () => { + const { tree, parser, root } = parse(`public interface Repository { + void save(); +} +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Repository"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Call Graph ---- + + describe("extractCallGraph", () => { + it("extracts simple method calls", () => { + const { tree, parser, root } = parse(`public class Foo { + public void process(int data) { + transform(data); + format(data); + } +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(2); + expect(result[0].caller).toBe("process"); + expect(result[0].callee).toBe("transform"); + expect(result[1].caller).toBe("process"); + expect(result[1].callee).toBe("format"); + + tree.delete(); + parser.delete(); + }); + + it("extracts qualified method calls (e.g. System.out.println)", () => { + const { tree, parser, root } = parse(`public class Foo { + private void log(String message) { + System.out.println(message); + } +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(1); + expect(result[0].caller).toBe("log"); + expect(result[0].callee).toBe("System.out.println"); + + tree.delete(); + parser.delete(); + }); + + it("extracts object creation expressions", () => { + const { tree, parser, root } = parse(`public class Foo { + public void create() { + Bar b = new Bar(); + } +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(1); + expect(result[0].caller).toBe("create"); + expect(result[0].callee).toBe("new Bar"); + + tree.delete(); + parser.delete(); + }); + + it("tracks correct caller for constructors", () => { + const { tree, parser, root } = parse(`public class Foo { + public Foo() { + init(); + } +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(1); + expect(result[0].caller).toBe("Foo"); + expect(result[0].callee).toBe("init"); + + tree.delete(); + parser.delete(); + }); + + it("reports correct line numbers for calls", () => { + const { tree, parser, root } = parse(`public class Foo { + public void run() { + foo(); + bar(); + } +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(2); + expect(result[0].lineNumber).toBe(3); + expect(result[1].lineNumber).toBe(4); + + tree.delete(); + parser.delete(); + }); + + it("ignores calls outside methods (no caller)", () => { + // Java doesn't really allow top-level calls, but field initializers + // can have method calls. We skip those without a method context. + const { tree, parser, root } = parse(`public class Foo { + private String value = String.valueOf(42); +} +`); + const result = extractor.extractCallGraph(root); + + // No enclosing method, so these are skipped + expect(result).toHaveLength(0); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Comprehensive ---- + + describe("comprehensive Java file", () => { + it("handles a realistic Java module", () => { + const { tree, parser, root } = parse(`import java.util.List; +import java.util.Map; + +public class UserService { + private String name; + private int maxRetries; + + public UserService(String name) { + this.name = name; + } + + public List getUsers(int limit) { + return fetchFromDb(limit); + } + + private void log(String message) { + System.out.println(message); + } +} + +interface Repository { + List findAll(); + User findById(int id); +} +`); + const result = extractor.extractStructure(root); + + // Functions: UserService (constructor), getUsers, log + expect(result.functions).toHaveLength(3); + expect(result.functions.map((f) => f.name).sort()).toEqual( + ["UserService", "getUsers", "log"].sort(), + ); + + // Constructor has params but no return type + const ctor = result.functions.find((f) => f.name === "UserService"); + expect(ctor?.params).toEqual(["name"]); + expect(ctor?.returnType).toBeUndefined(); + + // getUsers has params and generic return type + const getUsers = result.functions.find((f) => f.name === "getUsers"); + expect(getUsers?.params).toEqual(["limit"]); + expect(getUsers?.returnType).toBe("List"); + + // log has params and void return type + const log = result.functions.find((f) => f.name === "log"); + expect(log?.params).toEqual(["message"]); + expect(log?.returnType).toBe("void"); + + // Classes: UserService, Repository + expect(result.classes).toHaveLength(2); + + const userService = result.classes.find( + (c) => c.name === "UserService", + ); + expect(userService).toBeDefined(); + expect(userService!.methods.sort()).toEqual( + ["UserService", "getUsers", "log"].sort(), + ); + expect(userService!.properties.sort()).toEqual( + ["name", "maxRetries"].sort(), + ); + + const repository = result.classes.find( + (c) => c.name === "Repository", + ); + expect(repository).toBeDefined(); + expect(repository!.methods).toEqual(["findAll", "findById"]); + expect(repository!.properties).toEqual([]); + + // Imports: 2 (java.util.List, java.util.Map) + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("java.util.List"); + expect(result.imports[0].specifiers).toEqual(["List"]); + expect(result.imports[1].source).toBe("java.util.Map"); + expect(result.imports[1].specifiers).toEqual(["Map"]); + + // Exports: UserService (class), UserService (constructor), getUsers (public method) + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("UserService"); + expect(exportNames).toContain("getUsers"); + expect(exportNames).not.toContain("log"); // private + expect(exportNames).not.toContain("name"); // private field + expect(exportNames).not.toContain("maxRetries"); // private field + + // Call graph + const calls = extractor.extractCallGraph(root); + + const getUsersCalls = calls.filter((e) => e.caller === "getUsers"); + expect(getUsersCalls.some((e) => e.callee === "fetchFromDb")).toBe( + true, + ); + + const logCalls = calls.filter((e) => e.caller === "log"); + expect( + logCalls.some((e) => e.callee === "System.out.println"), + ).toBe(true); + + tree.delete(); + parser.delete(); + }); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/java-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/java-extractor.ts new file mode 100644 index 0000000..4ac3a4f --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/java-extractor.ts @@ -0,0 +1,449 @@ +import type { StructuralAnalysis, CallGraphEntry } from "../../types.js"; +import type { LanguageExtractor, TreeSitterNode } from "./types.js"; +import { findChild, findChildren } from "./base-extractor.js"; + +/** + * Extract parameter names from a Java `formal_parameters` node. + * + * Each `formal_parameter` child has a `name` field (identifier) and a `type` field. + */ +function extractParams(paramsNode: TreeSitterNode | null): string[] { + if (!paramsNode) return []; + const params: string[] = []; + + const declarations = findChildren(paramsNode, "formal_parameter"); + for (const decl of declarations) { + const nameNode = decl.childForFieldName("name"); + if (nameNode) { + params.push(nameNode.text); + } + } + + // Also handle spread_parameter (varargs): e.g. `String... args` + const spreadParams = findChildren(paramsNode, "spread_parameter"); + for (const spread of spreadParams) { + const nameNode = spread.childForFieldName("name"); + if (nameNode) { + params.push(nameNode.text); + } + } + + return params; +} + +/** + * Extract the return type text from a method_declaration node. + * + * In tree-sitter-java, the return type is the `type` named field on method_declaration. + * It can be a type_identifier, generic_type, void_type, integral_type, etc. + */ +function extractReturnType(node: TreeSitterNode): string | undefined { + const typeNode = node.childForFieldName("type"); + if (!typeNode) return undefined; + return typeNode.text; +} + +/** + * Check if a node has a `modifiers` child containing a specific modifier keyword. + */ +function hasModifier(node: TreeSitterNode, modifier: string): boolean { + const modifiers = findChild(node, "modifiers"); + if (!modifiers) return false; + for (let i = 0; i < modifiers.childCount; i++) { + const child = modifiers.child(i); + if (child && child.text === modifier) return true; + } + return false; +} + +/** + * Extract the full dotted path from a scoped_identifier node. + * + * Java's scoped_identifier nests recursively: + * `java.util.List` is scoped_identifier(scope: scoped_identifier(scope: identifier "java", + * name: identifier "util"), name: identifier "List") + * + * This returns the full path as a dotted string. + */ +function extractScopedIdentifierPath(node: TreeSitterNode): string { + return node.text; +} + +/** + * Get the last component of a dotted import path. + * e.g. "java.util.List" -> "List" + */ +function lastComponent(path: string): string { + const parts = path.split("."); + return parts[parts.length - 1]; +} + +/** + * Java extractor for tree-sitter structural analysis and call graph extraction. + * + * Handles classes, interfaces, methods, constructors, fields, imports, + * visibility-based exports, and call graphs for Java source code. + * + * Java-specific mapping decisions: + * - Classes and interfaces are mapped to the `classes` array. + * - Constructors are mapped to the `functions` array (named after the class). + * - Methods (including interface method signatures) are listed in the + * containing class/interface's `methods` array and also in the `functions` array. + * - Exports are determined by the `public` modifier on classes, methods, + * constructors, and fields. + * - Fields are extracted as `properties` from `field_declaration` nodes. + */ +export class JavaExtractor implements LanguageExtractor { + readonly languageIds = ["java"]; + + extractStructure(rootNode: TreeSitterNode): StructuralAnalysis { + const functions: StructuralAnalysis["functions"] = []; + const classes: StructuralAnalysis["classes"] = []; + const imports: StructuralAnalysis["imports"] = []; + const exports: StructuralAnalysis["exports"] = []; + + for (let i = 0; i < rootNode.childCount; i++) { + const node = rootNode.child(i); + if (!node) continue; + + switch (node.type) { + case "import_declaration": + this.extractImport(node, imports); + break; + + case "class_declaration": + this.extractClass(node, functions, classes, exports); + break; + + case "interface_declaration": + this.extractInterface(node, functions, classes, exports); + break; + } + } + + return { functions, classes, imports, exports }; + } + + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] { + const entries: CallGraphEntry[] = []; + const functionStack: string[] = []; + + const walkForCalls = (node: TreeSitterNode) => { + let pushedName = false; + + // Track entering method/constructor declarations + if ( + node.type === "method_declaration" || + node.type === "constructor_declaration" + ) { + const nameNode = node.childForFieldName("name"); + if (nameNode) { + functionStack.push(nameNode.text); + pushedName = true; + } + } + + // Extract method invocations: e.g. fetchFromDb(limit), System.out.println(msg) + if (node.type === "method_invocation") { + if (functionStack.length > 0) { + const callee = this.extractMethodInvocationName(node); + if (callee) { + entries.push({ + caller: functionStack[functionStack.length - 1], + callee, + lineNumber: node.startPosition.row + 1, + }); + } + } + } + + // Extract object creation: e.g. new Foo() + if (node.type === "object_creation_expression") { + if (functionStack.length > 0) { + const typeNode = node.childForFieldName("type"); + if (typeNode) { + entries.push({ + caller: functionStack[functionStack.length - 1], + callee: `new ${typeNode.text}`, + lineNumber: node.startPosition.row + 1, + }); + } + } + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) walkForCalls(child); + } + + if (pushedName) { + functionStack.pop(); + } + }; + + walkForCalls(rootNode); + + return entries; + } + + // ---- Private helpers ---- + + /** + * Extract the callee name from a method_invocation node. + * + * Handles: + * - Plain method call: `fetchFromDb(limit)` -> "fetchFromDb" + * - Qualified call: `System.out.println(msg)` -> "System.out.println" + */ + private extractMethodInvocationName(node: TreeSitterNode): string | null { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return null; + + const objectNode = node.childForFieldName("object"); + if (objectNode) { + return `${objectNode.text}.${nameNode.text}`; + } + + return nameNode.text; + } + + private extractImport( + node: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + // Check for asterisk (wildcard) import: `import java.util.*;` + const hasAsterisk = findChild(node, "asterisk") !== null; + + const scopedId = findChild(node, "scoped_identifier"); + if (!scopedId) return; + + const fullPath = extractScopedIdentifierPath(scopedId); + + if (hasAsterisk) { + // Wildcard import: source is the full scope, specifier is "*" + imports.push({ + source: fullPath, + specifiers: ["*"], + lineNumber: node.startPosition.row + 1, + }); + } else { + // Regular import: source is the full path, specifier is the last component + imports.push({ + source: fullPath, + specifiers: [lastComponent(fullPath)], + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractClass( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + classes: StructuralAnalysis["classes"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const methods: string[] = []; + const properties: string[] = []; + + const body = node.childForFieldName("body"); + if (body) { + this.extractClassBodyMembers( + body, + methods, + properties, + functions, + exports, + ); + } + + classes.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + methods, + properties, + }); + + if (hasModifier(node, "public")) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractInterface( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + classes: StructuralAnalysis["classes"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const methods: string[] = []; + const properties: string[] = []; + + const body = node.childForFieldName("body"); + if (body) { + // Interface body contains method_declaration nodes (signatures without bodies) + const methodNodes = findChildren(body, "method_declaration"); + for (const methodNode of methodNodes) { + const methNameNode = methodNode.childForFieldName("name"); + if (methNameNode) { + methods.push(methNameNode.text); + } + } + + // Interface can also contain constant_declaration (fields) + const fields = findChildren(body, "constant_declaration"); + for (const field of fields) { + const declarators = findChildren(field, "variable_declarator"); + for (const decl of declarators) { + const declName = decl.childForFieldName("name"); + if (declName) { + properties.push(declName.text); + } + } + } + } + + classes.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + methods, + properties, + }); + + if (hasModifier(node, "public")) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + /** + * Extract methods, constructors, and fields from a class_body node. + */ + private extractClassBodyMembers( + body: TreeSitterNode, + methods: string[], + properties: string[], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + ): void { + for (let i = 0; i < body.childCount; i++) { + const child = body.child(i); + if (!child) continue; + + switch (child.type) { + case "method_declaration": + this.extractMethod(child, methods, functions, exports); + break; + + case "constructor_declaration": + this.extractConstructor(child, methods, functions, exports); + break; + + case "field_declaration": + this.extractField(child, properties, exports); + break; + } + } + } + + private extractMethod( + node: TreeSitterNode, + methods: string[], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const paramsNode = node.childForFieldName("parameters"); + const params = extractParams(paramsNode ?? null); + const returnType = extractReturnType(node); + + methods.push(nameNode.text); + + functions.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + params, + returnType, + }); + + if (hasModifier(node, "public")) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractConstructor( + node: TreeSitterNode, + methods: string[], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const paramsNode = node.childForFieldName("parameters"); + const params = extractParams(paramsNode ?? null); + + methods.push(nameNode.text); + + functions.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + params, + // Constructors have no return type + }); + + if (hasModifier(node, "public")) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractField( + node: TreeSitterNode, + properties: string[], + exports: StructuralAnalysis["exports"], + ): void { + const declarators = findChildren(node, "variable_declarator"); + for (const decl of declarators) { + const nameNode = decl.childForFieldName("name"); + if (nameNode) { + properties.push(nameNode.text); + + if (hasModifier(node, "public")) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + } + } +} From dc8dd7dd77f09f8dfe77dff1b40cc88e60853a23 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 19:02:52 +0800 Subject: [PATCH 11/18] feat: add RubyExtractor for tree-sitter Ruby structural analysis Handles methods, classes, modules, attr_* properties, require imports, and call graph including bare identifier calls (no-arg method invocations). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/ruby-extractor.test.ts | 688 ++++++++++++++++++ .../src/plugins/extractors/ruby-extractor.ts | 420 +++++++++++ 2 files changed, 1108 insertions(+) create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/ruby-extractor.test.ts create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/ruby-extractor.ts diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/ruby-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/ruby-extractor.test.ts new file mode 100644 index 0000000..0172e7c --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/ruby-extractor.test.ts @@ -0,0 +1,688 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { createRequire } from "node:module"; +import { RubyExtractor } from "../ruby-extractor.js"; + +const require = createRequire(import.meta.url); + +// Load tree-sitter + Ruby grammar once +let Parser: any; +let Language: any; +let rubyLang: any; + +beforeAll(async () => { + const mod = await import("web-tree-sitter"); + Parser = mod.Parser; + Language = mod.Language; + await Parser.init(); + const wasmPath = require.resolve( + "tree-sitter-ruby/tree-sitter-ruby.wasm", + ); + rubyLang = await Language.load(wasmPath); +}); + +function parse(code: string) { + const parser = new Parser(); + parser.setLanguage(rubyLang); + const tree = parser.parse(code); + const root = tree.rootNode; + return { tree, parser, root }; +} + +describe("RubyExtractor", () => { + const extractor = new RubyExtractor(); + + it("has correct languageIds", () => { + expect(extractor.languageIds).toEqual(["ruby"]); + }); + + // ---- Functions / Methods ---- + + describe("extractStructure - functions", () => { + it("extracts simple methods", () => { + const { tree, parser, root } = parse(` +def hello(name) + puts name +end + +def add(a, b) + a + b +end +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(2); + + expect(result.functions[0].name).toBe("hello"); + expect(result.functions[0].params).toEqual(["name"]); + expect(result.functions[0].lineRange[0]).toBeGreaterThan(0); + + expect(result.functions[1].name).toBe("add"); + expect(result.functions[1].params).toEqual(["a", "b"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts methods with optional parameters", () => { + const { tree, parser, root } = parse(` +def connect(host, port = 8080, timeout = 30.0) +end +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("connect"); + expect(result.functions[0].params).toEqual(["host", "port", "timeout"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts methods with splat, hash splat, and block parameters", () => { + const { tree, parser, root } = parse(` +def flexible(*args, **kwargs, &block) +end +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].params).toEqual(["*args", "**kwargs", "&block"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts methods with no parameters", () => { + const { tree, parser, root } = parse(` +def noop +end +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("noop"); + expect(result.functions[0].params).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("does not assign return types (Ruby has none)", () => { + const { tree, parser, root } = parse(` +def compute(x) + x * 2 +end +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].returnType).toBeUndefined(); + + tree.delete(); + parser.delete(); + }); + + it("reports correct line ranges", () => { + const { tree, parser, root } = parse(` +def multiline( + a, + b +) + result = a + b + result +end +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].lineRange[0]).toBe(2); + expect(result.functions[0].lineRange[1]).toBe(8); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Classes ---- + + describe("extractStructure - classes", () => { + it("extracts classes with methods", () => { + const { tree, parser, root } = parse(` +class UserService + def initialize(name) + @name = name + end + + def find_user(id) + db_query(id) + end +end +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("UserService"); + expect(result.classes[0].methods).toContain("initialize"); + expect(result.classes[0].methods).toContain("find_user"); + + tree.delete(); + parser.delete(); + }); + + it("extracts attr_accessor, attr_reader, attr_writer as properties", () => { + const { tree, parser, root } = parse(` +class Model + attr_accessor :name, :email + attr_reader :id + attr_writer :status +end +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].properties).toEqual(["name", "email", "id", "status"]); + expect(result.classes[0].methods).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("extracts singleton methods (def self.foo) within classes", () => { + const { tree, parser, root } = parse(` +class Factory + def self.create(attrs) + new(attrs) + end + + def instance_method + end +end +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].methods).toContain("self.create"); + expect(result.classes[0].methods).toContain("instance_method"); + + tree.delete(); + parser.delete(); + }); + + it("also adds class methods to the functions array", () => { + const { tree, parser, root } = parse(` +class Svc + def run(x) + x + end +end +`); + const result = extractor.extractStructure(root); + + // Class methods appear in the top-level functions array + expect(result.functions.some((f) => f.name === "run")).toBe(true); + + tree.delete(); + parser.delete(); + }); + + it("extracts namespaced class names", () => { + const { tree, parser, root } = parse(` +class Foo::Bar +end +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Foo::Bar"); + + tree.delete(); + parser.delete(); + }); + + it("reports correct class line ranges", () => { + const { tree, parser, root } = parse(` +class MyClass + def method_a + end + + def method_b + end +end +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].lineRange[0]).toBe(2); + expect(result.classes[0].lineRange[1]).toBe(8); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Modules ---- + + describe("extractStructure - modules", () => { + it("treats modules as classes", () => { + const { tree, parser, root } = parse(` +module Helpers + def format_date(date) + date.strftime("%Y-%m-%d") + end +end +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Helpers"); + expect(result.classes[0].methods).toContain("format_date"); + + tree.delete(); + parser.delete(); + }); + + it("extracts module properties from attr_* calls", () => { + const { tree, parser, root } = parse(` +module Config + attr_accessor :debug +end +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].properties).toContain("debug"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Imports ---- + + describe("extractStructure - imports", () => { + it("extracts require statements", () => { + const { tree, parser, root } = parse(` +require "json" +require "net/http" +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("json"); + expect(result.imports[0].specifiers).toEqual(["json"]); + expect(result.imports[1].source).toBe("net/http"); + expect(result.imports[1].specifiers).toEqual(["net/http"]); + + tree.delete(); + parser.delete(); + }); + + it("extracts require_relative statements", () => { + const { tree, parser, root } = parse(` +require_relative "./helper" +require_relative "../lib/utils" +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("./helper"); + expect(result.imports[1].source).toBe("../lib/utils"); + + tree.delete(); + parser.delete(); + }); + + it("reports correct import line numbers", () => { + const { tree, parser, root } = parse(` +require "json" +require_relative "./helper" +`); + const result = extractor.extractStructure(root); + + expect(result.imports[0].lineNumber).toBe(2); + expect(result.imports[1].lineNumber).toBe(3); + + tree.delete(); + parser.delete(); + }); + + it("handles mixed require and require_relative", () => { + const { tree, parser, root } = parse(` +require "json" +require_relative "./helper" +require "yaml" +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(3); + expect(result.imports[0].source).toBe("json"); + expect(result.imports[1].source).toBe("./helper"); + expect(result.imports[2].source).toBe("yaml"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Exports ---- + + describe("extractStructure - exports", () => { + it("treats top-level methods as exports", () => { + const { tree, parser, root } = parse(` +def public_func +end + +def another_func(x) +end +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("public_func"); + expect(exportNames).toContain("another_func"); + expect(result.exports).toHaveLength(2); + + tree.delete(); + parser.delete(); + }); + + it("treats top-level classes as exports", () => { + const { tree, parser, root } = parse(` +class MyService +end + +class MyModel +end +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("MyService"); + expect(exportNames).toContain("MyModel"); + expect(result.exports).toHaveLength(2); + + tree.delete(); + parser.delete(); + }); + + it("treats top-level modules as exports", () => { + const { tree, parser, root } = parse(` +module Helpers +end +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Helpers"); + + tree.delete(); + parser.delete(); + }); + + it("does not treat imports as exports", () => { + const { tree, parser, root } = parse(` +require "json" +require_relative "./helper" + +def my_func +end +`); + const result = extractor.extractStructure(root); + + expect(result.exports).toHaveLength(1); + expect(result.exports[0].name).toBe("my_func"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Call Graph ---- + + describe("extractCallGraph", () => { + it("extracts simple method calls", () => { + const { tree, parser, root } = parse(` +def process(data) + result = transform(data) + format_output(result) +end + +def main + process([1, 2, 3]) +end +`); + const result = extractor.extractCallGraph(root); + + const processCallers = result.filter((e) => e.caller === "process"); + expect(processCallers.some((e) => e.callee === "transform")).toBe(true); + expect(processCallers.some((e) => e.callee === "format_output")).toBe(true); + + const mainCallers = result.filter((e) => e.caller === "main"); + expect(mainCallers.some((e) => e.callee === "process")).toBe(true); + + tree.delete(); + parser.delete(); + }); + + it("extracts receiver-based calls (method calls on objects)", () => { + const { tree, parser, root } = parse(` +def process + result.save + date.strftime("%Y-%m-%d") +end +`); + const result = extractor.extractCallGraph(root); + + const callees = result.map((e) => e.callee); + expect(callees).toContain("result.save"); + expect(callees).toContain("date.strftime"); + + tree.delete(); + parser.delete(); + }); + + it("tracks correct caller context for calls inside class methods", () => { + const { tree, parser, root } = parse(` +class Service + def start + setup + run_server + end +end +`); + const result = extractor.extractCallGraph(root); + + const startCalls = result.filter((e) => e.caller === "start"); + expect(startCalls.some((e) => e.callee === "setup")).toBe(true); + expect(startCalls.some((e) => e.callee === "run_server")).toBe(true); + + tree.delete(); + parser.delete(); + }); + + it("does not include require/require_relative in call graph", () => { + const { tree, parser, root } = parse(` +def setup + require "json" + do_work +end +`); + const result = extractor.extractCallGraph(root); + + const callees = result.map((e) => e.callee); + expect(callees).not.toContain("require"); + expect(callees).toContain("do_work"); + + tree.delete(); + parser.delete(); + }); + + it("does not include attr_* macros in call graph", () => { + const { tree, parser, root } = parse(` +class Foo + attr_accessor :bar + + def init + setup + end +end +`); + const result = extractor.extractCallGraph(root); + + const callees = result.map((e) => e.callee); + expect(callees).not.toContain("attr_accessor"); + expect(callees).toContain("setup"); + + tree.delete(); + parser.delete(); + }); + + it("reports correct line numbers for calls", () => { + const { tree, parser, root } = parse(` +def main + foo + bar +end +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(2); + expect(result[0].lineNumber).toBe(3); + expect(result[1].lineNumber).toBe(4); + + tree.delete(); + parser.delete(); + }); + + it("ignores top-level calls (no caller)", () => { + const { tree, parser, root } = parse(` +puts "hello" +main +`); + const result = extractor.extractCallGraph(root); + + // Top-level calls have no enclosing method, so they are skipped + expect(result).toHaveLength(0); + + tree.delete(); + parser.delete(); + }); + + it("tracks singleton method callers with self. prefix", () => { + const { tree, parser, root } = parse(` +class Foo + def self.create(attrs) + new(attrs) + end +end +`); + const result = extractor.extractCallGraph(root); + + const createCalls = result.filter((e) => e.caller === "self.create"); + expect(createCalls.some((e) => e.callee === "new")).toBe(true); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Comprehensive ---- + + describe("comprehensive Ruby file", () => { + it("handles the full test fixture", () => { + const { tree, parser, root } = parse(` +require "json" +require_relative "./helper" + +class UserService + attr_accessor :name, :email + attr_reader :id + + def initialize(name, email) + @name = name + @email = email + end + + def find_user(id) + result = db_query(id) + format_user(result) + end + + def self.create(attrs) + new(attrs[:name], attrs[:email]) + end +end + +module Helpers + def format_date(date) + date.strftime("%Y-%m-%d") + end +end + +def standalone_helper(x) + puts x.to_s +end +`); + const result = extractor.extractStructure(root); + + // Functions: initialize, find_user, self.create, format_date, standalone_helper + const funcNames = result.functions.map((f) => f.name); + expect(funcNames).toContain("initialize"); + expect(funcNames).toContain("find_user"); + expect(funcNames).toContain("self.create"); + expect(funcNames).toContain("format_date"); + expect(funcNames).toContain("standalone_helper"); + expect(result.functions).toHaveLength(5); + + // Classes: UserService (methods: initialize, find_user, self.create; properties: name, email, id) + expect(result.classes).toHaveLength(2); + + const userService = result.classes.find((c) => c.name === "UserService"); + expect(userService).toBeDefined(); + expect(userService!.methods).toContain("initialize"); + expect(userService!.methods).toContain("find_user"); + expect(userService!.methods).toContain("self.create"); + expect(userService!.properties).toEqual( + expect.arrayContaining(["name", "email", "id"]), + ); + + // Helpers module (methods: format_date) + const helpers = result.classes.find((c) => c.name === "Helpers"); + expect(helpers).toBeDefined(); + expect(helpers!.methods).toContain("format_date"); + + // Imports: 2 (json, ./helper) + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("json"); + expect(result.imports[1].source).toBe("./helper"); + + // Exports: UserService, Helpers, standalone_helper (all top-level) + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("UserService"); + expect(exportNames).toContain("Helpers"); + expect(exportNames).toContain("standalone_helper"); + + // Call graph + const calls = extractor.extractCallGraph(root); + + // find_user -> db_query, find_user -> format_user + const findUserCalls = calls.filter((e) => e.caller === "find_user"); + expect(findUserCalls.some((e) => e.callee === "db_query")).toBe(true); + expect(findUserCalls.some((e) => e.callee === "format_user")).toBe(true); + + // standalone_helper -> puts + const standaloneHelperCalls = calls.filter( + (e) => e.caller === "standalone_helper", + ); + expect(standaloneHelperCalls.some((e) => e.callee === "puts")).toBe(true); + + // Verify require/require_relative not in call graph + const allCallees = calls.map((e) => e.callee); + expect(allCallees).not.toContain("require"); + expect(allCallees).not.toContain("require_relative"); + + tree.delete(); + parser.delete(); + }); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/ruby-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/ruby-extractor.ts new file mode 100644 index 0000000..e4f115c --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/ruby-extractor.ts @@ -0,0 +1,420 @@ +import type { StructuralAnalysis, CallGraphEntry } from "../../types.js"; +import type { LanguageExtractor, TreeSitterNode } from "./types.js"; +import { findChild, findChildren } from "./base-extractor.js"; + +/** + * Set of method names that Ruby uses for imports. + * These are handled separately from regular call graph entries. + */ +const IMPORT_METHODS = new Set(["require", "require_relative"]); + +/** + * Set of method names that define class properties (attr_* macros). + * Their arguments are symbols that become accessor methods / properties. + */ +const ATTR_METHODS = new Set(["attr_accessor", "attr_reader", "attr_writer"]); + +/** + * Extract parameter names from a Ruby `method_parameters` node. + * + * Handles: identifier (plain), optional_parameter (with default), + * splat_parameter (*args), hash_splat_parameter (**kwargs), + * block_parameter (&block). + */ +function extractParams(paramsNode: TreeSitterNode | null): string[] { + if (!paramsNode) return []; + const params: string[] = []; + + for (let i = 0; i < paramsNode.childCount; i++) { + const child = paramsNode.child(i); + if (!child) continue; + + switch (child.type) { + case "identifier": + params.push(child.text); + break; + + case "optional_parameter": { + const ident = child.childForFieldName("name"); + if (ident) params.push(ident.text); + break; + } + + case "splat_parameter": { + const ident = child.childForFieldName("name"); + if (ident) params.push("*" + ident.text); + break; + } + + case "hash_splat_parameter": { + const ident = child.childForFieldName("name"); + if (ident) params.push("**" + ident.text); + break; + } + + case "block_parameter": { + const ident = child.childForFieldName("name"); + if (ident) params.push("&" + ident.text); + break; + } + } + } + + return params; +} + +/** + * Extract property names from attr_accessor/attr_reader/attr_writer calls. + * These calls take symbol arguments like `:name, :email`. + */ +function extractAttrProperties(callNode: TreeSitterNode): string[] { + const properties: string[] = []; + const args = callNode.childForFieldName("arguments"); + if (!args) return properties; + + for (let i = 0; i < args.childCount; i++) { + const child = args.child(i); + if (child && child.type === "simple_symbol") { + // Strip leading colon from `:name` -> `name` + properties.push(child.text.slice(1)); + } + } + + return properties; +} + +/** + * Extract the string value from a Ruby string node. + * Ruby strings have a `string_content` child containing the unquoted value. + */ +function getStringContent(node: TreeSitterNode): string { + const content = findChild(node, "string_content"); + if (content) return content.text; + // Fallback: strip surrounding quotes + return node.text.replace(/^['"`]|['"`]$/g, ""); +} + +/** + * Ruby extractor for tree-sitter structural analysis and call graph extraction. + * + * Handles methods, classes, modules, require imports, and call graphs + * for Ruby source code. + * + * Ruby-specific mapping decisions: + * - Both `class` and `module` nodes are mapped to the `classes` array. + * - `singleton_method` (def self.foo) is prefixed with "self." in the name. + * - `attr_accessor`/`attr_reader`/`attr_writer` define properties on classes. + * - `require` and `require_relative` calls are mapped to imports. + * - All top-level definitions (classes, modules, methods) are treated as exports, + * since Ruby has no formal export syntax. + */ +export class RubyExtractor implements LanguageExtractor { + readonly languageIds = ["ruby"]; + + extractStructure(rootNode: TreeSitterNode): StructuralAnalysis { + const functions: StructuralAnalysis["functions"] = []; + const classes: StructuralAnalysis["classes"] = []; + const imports: StructuralAnalysis["imports"] = []; + const exports: StructuralAnalysis["exports"] = []; + + for (let i = 0; i < rootNode.childCount; i++) { + const node = rootNode.child(i); + if (!node) continue; + + switch (node.type) { + case "method": + this.extractMethod(node, functions); + exports.push({ + name: this.getMethodName(node), + lineNumber: node.startPosition.row + 1, + }); + break; + + case "singleton_method": + this.extractSingletonMethod(node, functions); + exports.push({ + name: "self." + this.getSingletonMethodName(node), + lineNumber: node.startPosition.row + 1, + }); + break; + + case "class": + this.extractClass(node, classes, functions); + exports.push({ + name: this.getClassName(node), + lineNumber: node.startPosition.row + 1, + }); + break; + + case "module": + this.extractModule(node, classes, functions); + exports.push({ + name: this.getModuleName(node), + lineNumber: node.startPosition.row + 1, + }); + break; + + case "call": + this.extractTopLevelCall(node, imports); + break; + } + } + + return { functions, classes, imports, exports }; + } + + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] { + const entries: CallGraphEntry[] = []; + const functionStack: string[] = []; + + const walkForCalls = (node: TreeSitterNode) => { + let pushedName = false; + + // Track entering method definitions + if (node.type === "method") { + const nameNode = node.childForFieldName("name"); + if (nameNode) { + functionStack.push(nameNode.text); + pushedName = true; + } + } else if (node.type === "singleton_method") { + const nameNode = node.childForFieldName("name"); + if (nameNode) { + functionStack.push("self." + nameNode.text); + pushedName = true; + } + } + + // Extract call expressions (but not imports or attr_* macros) + if (node.type === "call") { + const methodNode = node.childForFieldName("method"); + if (methodNode && functionStack.length > 0) { + const methodName = methodNode.text; + + // Skip require/require_relative (imports) and attr_* macros + if (!IMPORT_METHODS.has(methodName) && !ATTR_METHODS.has(methodName)) { + const receiverNode = node.childForFieldName("receiver"); + const callee = receiverNode + ? receiverNode.text + "." + methodName + : methodName; + + entries.push({ + caller: functionStack[functionStack.length - 1], + callee, + lineNumber: node.startPosition.row + 1, + }); + } + } + } + + // Ruby bare method calls without arguments (e.g., `setup`) are parsed as + // `identifier` nodes inside `body_statement`, not as `call` nodes. + // Treat them as calls when inside a function context. + if ( + node.type === "identifier" && + node.parent?.type === "body_statement" && + functionStack.length > 0 + ) { + entries.push({ + caller: functionStack[functionStack.length - 1], + callee: node.text, + lineNumber: node.startPosition.row + 1, + }); + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) walkForCalls(child); + } + + if (pushedName) { + functionStack.pop(); + } + }; + + walkForCalls(rootNode); + + return entries; + } + + // ---- Private helpers ---- + + private getMethodName(node: TreeSitterNode): string { + const nameNode = node.childForFieldName("name"); + return nameNode ? nameNode.text : ""; + } + + private getSingletonMethodName(node: TreeSitterNode): string { + const nameNode = node.childForFieldName("name"); + return nameNode ? nameNode.text : ""; + } + + private getClassName(node: TreeSitterNode): string { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return ""; + // Can be `constant` ("Foo") or `scope_resolution` ("Foo::Bar") + return nameNode.text; + } + + private getModuleName(node: TreeSitterNode): string { + const nameNode = node.childForFieldName("name"); + return nameNode ? nameNode.text : ""; + } + + private extractMethod( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const paramsNode = node.childForFieldName("parameters"); + const params = extractParams(paramsNode ?? null); + + functions.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + params, + }); + } + + private extractSingletonMethod( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const paramsNode = node.childForFieldName("parameters"); + const params = extractParams(paramsNode ?? null); + + functions.push({ + name: "self." + nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + params, + }); + } + + private extractClass( + node: TreeSitterNode, + classes: StructuralAnalysis["classes"], + functions: StructuralAnalysis["functions"], + ): void { + const name = this.getClassName(node); + if (!name) return; + + const methods: string[] = []; + const properties: string[] = []; + + const body = node.childForFieldName("body"); + if (body) { + this.extractClassBody(body, methods, properties, functions); + } + + classes.push({ + name, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + methods, + properties, + }); + } + + private extractModule( + node: TreeSitterNode, + classes: StructuralAnalysis["classes"], + functions: StructuralAnalysis["functions"], + ): void { + const name = this.getModuleName(node); + if (!name) return; + + const methods: string[] = []; + const properties: string[] = []; + + const body = node.childForFieldName("body"); + if (body) { + this.extractClassBody(body, methods, properties, functions); + } + + classes.push({ + name, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + methods, + properties, + }); + } + + /** + * Extract methods and properties from a class/module body_statement. + * Also pushes each method into the top-level functions array. + */ + private extractClassBody( + body: TreeSitterNode, + methods: string[], + properties: string[], + functions: StructuralAnalysis["functions"], + ): void { + for (let i = 0; i < body.childCount; i++) { + const member = body.child(i); + if (!member) continue; + + if (member.type === "method") { + const nameNode = member.childForFieldName("name"); + if (nameNode) { + methods.push(nameNode.text); + this.extractMethod(member, functions); + } + } else if (member.type === "singleton_method") { + const nameNode = member.childForFieldName("name"); + if (nameNode) { + methods.push("self." + nameNode.text); + this.extractSingletonMethod(member, functions); + } + } else if (member.type === "call") { + // Check for attr_accessor/attr_reader/attr_writer + const methodNode = member.childForFieldName("method"); + if (methodNode && ATTR_METHODS.has(methodNode.text)) { + properties.push(...extractAttrProperties(member)); + } + } + } + } + + /** + * Handle top-level call nodes: extract require/require_relative as imports. + */ + private extractTopLevelCall( + node: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + const methodNode = node.childForFieldName("method"); + if (!methodNode) return; + + if (IMPORT_METHODS.has(methodNode.text)) { + const args = node.childForFieldName("arguments"); + if (!args) return; + + // The first argument is typically the string source + const firstArg = findChild(args, "string"); + if (firstArg) { + const source = getStringContent(firstArg); + imports.push({ + source, + specifiers: [source], + lineNumber: node.startPosition.row + 1, + }); + } + } + } +} From 4eb35b04373e1b4b77bbcccb2f8ce13aa20c444c Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 19:08:51 +0800 Subject: [PATCH 12/18] feat: add PhpExtractor for tree-sitter PHP structural analysis Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/php-extractor.test.ts | 604 ++++++++++++++++++ .../src/plugins/extractors/php-extractor.ts | 435 +++++++++++++ 2 files changed, 1039 insertions(+) create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/php-extractor.test.ts create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/php-extractor.ts diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/php-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/php-extractor.test.ts new file mode 100644 index 0000000..d08b4d4 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/php-extractor.test.ts @@ -0,0 +1,604 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { createRequire } from "node:module"; +import { PhpExtractor } from "../php-extractor.js"; + +const require = createRequire(import.meta.url); + +// Load tree-sitter + PHP grammar once +let Parser: any; +let Language: any; +let phpLang: any; + +beforeAll(async () => { + const mod = await import("web-tree-sitter"); + Parser = mod.Parser; + Language = mod.Language; + await Parser.init(); + const wasmPath = require.resolve( + "tree-sitter-php/tree-sitter-php.wasm", + ); + phpLang = await Language.load(wasmPath); +}); + +function parse(code: string) { + const parser = new Parser(); + parser.setLanguage(phpLang); + const tree = parser.parse(code); + const root = tree.rootNode; + return { tree, parser, root }; +} + +describe("PhpExtractor", () => { + const extractor = new PhpExtractor(); + + it("has correct languageIds", () => { + expect(extractor.languageIds).toEqual(["php"]); + }); + + // ---- Functions ---- + + describe("extractStructure - functions", () => { + it("extracts top-level functions with params and return types", () => { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` { + it("extracts classes with methods and properties", () => { + const { tree, parser, root } = parse(`name = $name; + } + + public function getUser(int $id): User { + return $this->fetchFromDb($id); + } + + private function log(string $message): void { + error_log($message); + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("UserService"); + expect(result.classes[0].methods).toContain("__construct"); + expect(result.classes[0].methods).toContain("getUser"); + expect(result.classes[0].methods).toContain("log"); + expect(result.classes[0].methods).toHaveLength(3); + expect(result.classes[0].properties).toContain("name"); + expect(result.classes[0].properties).toContain("maxRetries"); + expect(result.classes[0].properties).toHaveLength(2); + + tree.delete(); + parser.delete(); + }); + + it("also adds class methods to the functions array", () => { + const { tree, parser, root } = parse(` f.name === "run")).toBe(true); + expect(result.functions[0].params).toEqual(["$x"]); + expect(result.functions[0].returnType).toBe("string"); + + tree.delete(); + parser.delete(); + }); + + it("extracts classes with static methods", () => { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` { + it("extracts interfaces with method signatures", () => { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` e.name); + expect(exportNames).toContain("Repository"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Imports (use statements) ---- + + describe("extractStructure - imports", () => { + it("extracts simple use statements", () => { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` { + it("treats top-level functions as exports", () => { + const { tree, parser, root } = parse(` e.name); + expect(exportNames).toContain("publicFunc"); + expect(exportNames).toContain("anotherFunc"); + expect(result.exports).toHaveLength(2); + + tree.delete(); + parser.delete(); + }); + + it("treats classes as exports", () => { + const { tree, parser, root } = parse(` e.name); + expect(exportNames).toContain("MyService"); + expect(exportNames).toContain("MyModel"); + expect(result.exports).toHaveLength(2); + + tree.delete(); + parser.delete(); + }); + + it("does not treat use statements as exports", () => { + const { tree, parser, root } = parse(` { + it("extracts standalone function calls", () => { + const { tree, parser, root } = parse(` e.callee); + expect(result.every((e) => e.caller === "process")).toBe(true); + expect(callees).toContain("transform"); + expect(callees).toContain("format_output"); + + tree.delete(); + parser.delete(); + }); + + it("extracts instance method calls ($this->method())", () => { + const { tree, parser, root } = parse(`fetchFromDb($id); + } +} +`); + const result = extractor.extractCallGraph(root); + + expect(result.some((e) => e.caller === "getUser" && e.callee === "$this->fetchFromDb")).toBe(true); + + tree.delete(); + parser.delete(); + }); + + it("extracts static method calls (Class::method())", () => { + const { tree, parser, root } = parse(` e.caller === "doWork" && e.callee === "Bar::staticMethod")).toBe(true); + + tree.delete(); + parser.delete(); + }); + + it("tracks correct caller context across nested calls", () => { + const { tree, parser, root } = parse(`setup(); + run_server(); + } + + private function setup(): void { + init_config(); + } +} +`); + const result = extractor.extractCallGraph(root); + + const startCalls = result.filter((e) => e.caller === "start"); + expect(startCalls.some((e) => e.callee === "$this->setup")).toBe(true); + expect(startCalls.some((e) => e.callee === "run_server")).toBe(true); + + const setupCalls = result.filter((e) => e.caller === "setup"); + expect(setupCalls.some((e) => e.callee === "init_config")).toBe(true); + + tree.delete(); + parser.delete(); + }); + + it("ignores top-level calls (no caller)", () => { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` { + it("handles the full test fixture", () => { + const { tree, parser, root } = parse(`name = $name; + } + + public function getUser(int $id): User { + return $this->fetchFromDb($id); + } + + private function log(string $message): void { + error_log($message); + } +} + +function helper(string $x): string { + return strtoupper($x); +} +`); + const result = extractor.extractStructure(root); + + // Functions: __construct, getUser, log (from class), helper (top-level) + const funcNames = result.functions.map((f) => f.name); + expect(funcNames).toContain("__construct"); + expect(funcNames).toContain("getUser"); + expect(funcNames).toContain("log"); + expect(funcNames).toContain("helper"); + expect(result.functions).toHaveLength(4); + + // Classes: UserService + expect(result.classes).toHaveLength(1); + const userService = result.classes[0]; + expect(userService.name).toBe("UserService"); + expect(userService.methods).toContain("__construct"); + expect(userService.methods).toContain("getUser"); + expect(userService.methods).toContain("log"); + expect(userService.properties).toContain("name"); + expect(userService.properties).toContain("maxRetries"); + + // Imports: 2 use statements + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("App\\Models\\User"); + expect(result.imports[0].specifiers).toEqual(["User"]); + expect(result.imports[1].source).toBe("App\\Contracts\\Repository"); + expect(result.imports[1].specifiers).toEqual(["Repository"]); + + // Exports: UserService (class) + helper (function) + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("UserService"); + expect(exportNames).toContain("helper"); + expect(result.exports).toHaveLength(2); + + // Return types + const getUser = result.functions.find((f) => f.name === "getUser"); + expect(getUser).toBeDefined(); + expect(getUser!.returnType).toBe("User"); + + const log = result.functions.find((f) => f.name === "log"); + expect(log).toBeDefined(); + expect(log!.returnType).toBe("void"); + + const helper = result.functions.find((f) => f.name === "helper"); + expect(helper).toBeDefined(); + expect(helper!.returnType).toBe("string"); + + // Call graph + const calls = extractor.extractCallGraph(root); + + // getUser -> $this->fetchFromDb + const getUserCalls = calls.filter((e) => e.caller === "getUser"); + expect(getUserCalls.some((e) => e.callee === "$this->fetchFromDb")).toBe(true); + + // log -> error_log + const logCalls = calls.filter((e) => e.caller === "log"); + expect(logCalls.some((e) => e.callee === "error_log")).toBe(true); + + // helper -> strtoupper + const helperCalls = calls.filter((e) => e.caller === "helper"); + expect(helperCalls.some((e) => e.callee === "strtoupper")).toBe(true); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Nullable return types ---- + + describe("nullable return types", () => { + it("extracts nullable return type", () => { + const { tree, parser, root } = parse(` "User" + */ +function lastSegment(fqn: string): string { + const parts = fqn.split("\\"); + return parts[parts.length - 1]; +} + +/** + * PHP extractor for tree-sitter structural analysis and call graph extraction. + * + * Handles functions, classes, interfaces, use imports, and call graphs + * for PHP source code parsed by tree-sitter-php. + * + * PHP-specific mapping decisions: + * - `function_definition` nodes map to the `functions` array. + * - `class_declaration` and `interface_declaration` map to the `classes` array. + * - `property_declaration` nodes within classes map to class properties. + * - `namespace_use_declaration` nodes (PHP `use` statements) map to imports. + * - PHP has no formal export syntax, so public classes, interfaces, and + * top-level functions are treated as exports. + * - Call graph covers `function_call_expression`, `member_call_expression`, + * and `scoped_call_expression`. + */ +export class PhpExtractor implements LanguageExtractor { + readonly languageIds = ["php"]; + + extractStructure(rootNode: TreeSitterNode): StructuralAnalysis { + const functions: StructuralAnalysis["functions"] = []; + const classes: StructuralAnalysis["classes"] = []; + const imports: StructuralAnalysis["imports"] = []; + const exports: StructuralAnalysis["exports"] = []; + + // tree-sitter-php wraps everything under `program`. The children of + // `program` include `php_tag`, `namespace_definition`, `namespace_use_declaration`, + // `class_declaration`, `function_definition`, etc. + for (let i = 0; i < rootNode.childCount; i++) { + const node = rootNode.child(i); + if (!node) continue; + + switch (node.type) { + case "function_definition": + this.extractFunction(node, functions); + exports.push({ + name: this.getFunctionName(node), + lineNumber: node.startPosition.row + 1, + }); + break; + + case "class_declaration": + this.extractClass(node, classes, functions); + exports.push({ + name: this.getClassName(node), + lineNumber: node.startPosition.row + 1, + }); + break; + + case "interface_declaration": + this.extractInterface(node, classes); + exports.push({ + name: this.getInterfaceName(node), + lineNumber: node.startPosition.row + 1, + }); + break; + + case "namespace_use_declaration": + this.extractUseDeclaration(node, imports); + break; + } + } + + return { functions, classes, imports, exports }; + } + + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] { + const entries: CallGraphEntry[] = []; + const functionStack: string[] = []; + + const walkForCalls = (node: TreeSitterNode) => { + let pushedName = false; + + // Track entering function/method definitions + if (node.type === "function_definition" || node.type === "method_declaration") { + const nameNode = findChild(node, "name"); + if (nameNode) { + functionStack.push(nameNode.text); + pushedName = true; + } + } + + // Extract call expressions + if (functionStack.length > 0) { + const caller = functionStack[functionStack.length - 1]; + + if (node.type === "function_call_expression") { + // Standalone function call: baz($x), strtoupper($x), error_log($msg) + const nameNode = findChild(node, "name"); + if (nameNode) { + entries.push({ + caller, + callee: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } else if (node.type === "member_call_expression") { + // Instance method call: $this->fetchFromDb($id) + const nameNode = findChild(node, "name"); + if (nameNode) { + // Determine the receiver for more descriptive callee + const firstChild = node.child(0); + const receiver = firstChild ? firstChild.text : ""; + const callee = receiver + ? receiver + "->" + nameNode.text + : nameNode.text; + entries.push({ + caller, + callee, + lineNumber: node.startPosition.row + 1, + }); + } + } else if (node.type === "scoped_call_expression") { + // Static method call: Bar::staticMethod() + // Children: [name("Bar"), ::, name("staticMethod"), arguments] + // Both scope and method are `name` nodes, so we pick child[0] for scope + // and child[2] (after `::`) for the method name. + const scopeNode = node.child(0); + const methodNode = node.child(2); + if (scopeNode && methodNode && methodNode.type === "name") { + entries.push({ + caller, + callee: scopeNode.text + "::" + methodNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) walkForCalls(child); + } + + if (pushedName) { + functionStack.pop(); + } + }; + + walkForCalls(rootNode); + + return entries; + } + + // ---- Private helpers ---- + + private getFunctionName(node: TreeSitterNode): string { + const nameNode = findChild(node, "name"); + return nameNode ? nameNode.text : ""; + } + + private getClassName(node: TreeSitterNode): string { + const nameNode = findChild(node, "name"); + return nameNode ? nameNode.text : ""; + } + + private getInterfaceName(node: TreeSitterNode): string { + const nameNode = findChild(node, "name"); + return nameNode ? nameNode.text : ""; + } + + private extractFunction( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + ): void { + const nameNode = findChild(node, "name"); + if (!nameNode) return; + + const paramsNode = findChild(node, "formal_parameters"); + const params = extractParams(paramsNode); + const returnType = extractReturnType(node); + + functions.push({ + name: nameNode.text, + lineRange: [node.startPosition.row + 1, node.endPosition.row + 1], + params, + returnType, + }); + } + + private extractClass( + node: TreeSitterNode, + classes: StructuralAnalysis["classes"], + functions: StructuralAnalysis["functions"], + ): void { + const name = this.getClassName(node); + if (!name) return; + + const methods: string[] = []; + const properties: string[] = []; + + const declList = findChild(node, "declaration_list"); + if (declList) { + this.extractDeclarationList(declList, methods, properties, functions); + } + + classes.push({ + name, + lineRange: [node.startPosition.row + 1, node.endPosition.row + 1], + methods, + properties, + }); + } + + private extractInterface( + node: TreeSitterNode, + classes: StructuralAnalysis["classes"], + ): void { + const name = this.getInterfaceName(node); + if (!name) return; + + const methods: string[] = []; + const properties: string[] = []; + + const declList = findChild(node, "declaration_list"); + if (declList) { + // Interface methods are method_declaration nodes (no bodies, just signatures) + const methodDecls = findChildren(declList, "method_declaration"); + for (const methodDecl of methodDecls) { + const methodName = findChild(methodDecl, "name"); + if (methodName) { + methods.push(methodName.text); + } + } + } + + classes.push({ + name, + lineRange: [node.startPosition.row + 1, node.endPosition.row + 1], + methods, + properties, + }); + } + + /** + * Extract methods and properties from a class `declaration_list`. + * Also pushes each method into the top-level functions array. + */ + private extractDeclarationList( + declList: TreeSitterNode, + methods: string[], + properties: string[], + functions: StructuralAnalysis["functions"], + ): void { + for (let i = 0; i < declList.childCount; i++) { + const member = declList.child(i); + if (!member) continue; + + if (member.type === "method_declaration") { + const nameNode = findChild(member, "name"); + if (nameNode) { + methods.push(nameNode.text); + + // Also add to functions array + const paramsNode = findChild(member, "formal_parameters"); + const params = extractParams(paramsNode); + const returnType = extractReturnType(member); + + functions.push({ + name: nameNode.text, + lineRange: [member.startPosition.row + 1, member.endPosition.row + 1], + params, + returnType, + }); + } + } else if (member.type === "property_declaration") { + // Extract property name from property_element -> variable_name + const propElement = findChild(member, "property_element"); + if (propElement) { + const varName = findChild(propElement, "variable_name"); + if (varName) { + // Get just the name part without $ + const dollarChild = findChild(varName, "name"); + if (dollarChild) { + properties.push(dollarChild.text); + } else { + // Fallback: use the full text and strip $ + properties.push(varName.text.replace(/^\$/, "")); + } + } + } + } + } + } + + /** + * Extract imports from a `namespace_use_declaration` node. + * + * Handles: + * - Simple: `use App\Models\User;` + * - Aliased: `use App\Contracts\Repository as Repo;` + * - Grouped: `use App\Models\{User, Post};` + */ + private extractUseDeclaration( + node: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + // Check for grouped use: `use Namespace\{A, B};` + const useGroup = findChild(node, "namespace_use_group"); + if (useGroup) { + // Reconstruct the prefix from the namespace_name preceding the group + const nsName = findChild(node, "namespace_name"); + const prefix = nsName ? nsName.text : ""; + + const clauses = findChildren(useGroup, "namespace_use_clause"); + const specifiers: string[] = []; + for (const clause of clauses) { + const name = extractUseName(clause, prefix); + specifiers.push(lastSegment(name)); + } + + const source = prefix + ? prefix + "\\{" + specifiers.join(", ") + "}" + : specifiers.join(", "); + + imports.push({ + source, + specifiers, + lineNumber: node.startPosition.row + 1, + }); + return; + } + + // Simple or aliased use declaration + const clauses = findChildren(node, "namespace_use_clause"); + for (const clause of clauses) { + const fqn = extractUseName(clause, ""); + const specifier = lastSegment(fqn); + + imports.push({ + source: fqn, + specifiers: [specifier], + lineNumber: node.startPosition.row + 1, + }); + } + } +} From 48e1daa838c0a4b966b26196aafb78dfc9970b49 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 19:13:59 +0800 Subject: [PATCH 13/18] feat: add CppExtractor for tree-sitter C/C++ structural analysis Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/cpp-extractor.test.ts | 696 ++++++++++++++++++ .../src/plugins/extractors/cpp-extractor.ts | 501 +++++++++++++ 2 files changed, 1197 insertions(+) create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/cpp-extractor.test.ts create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/cpp-extractor.ts diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/cpp-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/cpp-extractor.test.ts new file mode 100644 index 0000000..3dd30ca --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/cpp-extractor.test.ts @@ -0,0 +1,696 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { createRequire } from "node:module"; +import { CppExtractor } from "../cpp-extractor.js"; + +const require = createRequire(import.meta.url); + +// Load tree-sitter + C++ grammar once +let Parser: any; +let Language: any; +let cppLang: any; + +beforeAll(async () => { + const mod = await import("web-tree-sitter"); + Parser = mod.Parser; + Language = mod.Language; + await Parser.init(); + const wasmPath = require.resolve( + "tree-sitter-cpp/tree-sitter-cpp.wasm", + ); + cppLang = await Language.load(wasmPath); +}); + +function parse(code: string) { + const parser = new Parser(); + parser.setLanguage(cppLang); + const tree = parser.parse(code); + const root = tree.rootNode; + return { tree, parser, root }; +} + +describe("CppExtractor", () => { + const extractor = new CppExtractor(); + + it("has correct languageIds", () => { + expect(extractor.languageIds).toEqual(["cpp"]); + }); + + // ---- Functions ---- + + describe("extractStructure - functions", () => { + it("extracts top-level functions with params and return types", () => { + const { tree, parser, root } = parse(` +int add(int a, int b) { + return a + b; +} + +void greet(const char* name) { + printf("Hello %s", name); +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(2); + + expect(result.functions[0].name).toBe("add"); + expect(result.functions[0].params).toEqual(["a", "b"]); + expect(result.functions[0].returnType).toBe("int"); + + expect(result.functions[1].name).toBe("greet"); + expect(result.functions[1].params).toEqual(["name"]); + expect(result.functions[1].returnType).toBe("void"); + + tree.delete(); + parser.delete(); + }); + + it("extracts functions with no params", () => { + const { tree, parser, root } = parse(` +int get_value() { + return 42; +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("get_value"); + expect(result.functions[0].params).toEqual([]); + expect(result.functions[0].returnType).toBe("int"); + + tree.delete(); + parser.delete(); + }); + + it("reports correct line ranges for multi-line functions", () => { + const { tree, parser, root } = parse(` +int multiline( + int a, + int b +) { + int result = a + b; + return result; +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].lineRange[0]).toBe(2); + expect(result.functions[0].lineRange[1]).toBe(8); + + tree.delete(); + parser.delete(); + }); + + it("handles pointer and reference parameters", () => { + const { tree, parser, root } = parse(` +void process(int* ptr, const char& ref, int arr[]) { +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].params).toEqual(["ptr", "ref", "arr"]); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Classes ---- + + describe("extractStructure - classes", () => { + it("extracts class with properties and method declarations", () => { + const { tree, parser, root } = parse(` +class Server { +public: + std::string host; + int port; + + void start(); + int getPort() { return port; } +}; +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Server"); + expect(result.classes[0].properties).toEqual(["host", "port"]); + expect(result.classes[0].methods).toContain("start"); + expect(result.classes[0].methods).toContain("getPort"); + + tree.delete(); + parser.delete(); + }); + + it("respects access specifiers for exports", () => { + const { tree, parser, root } = parse(` +class Foo { +private: + int secret; + void hidden(); +public: + int visible; + void exposed(); +}; +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + // Public members should be exported + expect(exportNames).toContain("exposed"); + // Private members should NOT be exported (except the class name itself) + expect(exportNames).not.toContain("hidden"); + expect(exportNames).not.toContain("secret"); + // The class itself is always exported + expect(exportNames).toContain("Foo"); + + tree.delete(); + parser.delete(); + }); + + it("defaults class members to private access", () => { + const { tree, parser, root } = parse(` +class Priv { + int x; + void secret(); +}; +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Priv"); + // Members without access specifier in a class default to private + expect(exportNames).not.toContain("secret"); + + tree.delete(); + parser.delete(); + }); + + it("handles inline method definitions (function_definition inside class)", () => { + const { tree, parser, root } = parse(` +class Calculator { +public: + int add(int a, int b) { return a + b; } +}; +`); + const result = extractor.extractStructure(root); + + // Inline method should appear in both classes.methods and functions + expect(result.classes[0].methods).toContain("add"); + + const addFn = result.functions.find((f) => f.name === "add"); + expect(addFn).toBeDefined(); + expect(addFn!.params).toEqual(["a", "b"]); + expect(addFn!.returnType).toBe("int"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Structs ---- + + describe("extractStructure - structs", () => { + it("extracts struct with fields", () => { + const { tree, parser, root } = parse(` +struct Point { + int x; + int y; +}; +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Point"); + expect(result.classes[0].properties).toEqual(["x", "y"]); + expect(result.classes[0].methods).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("defaults struct members to public access and exports them", () => { + const { tree, parser, root } = parse(` +struct Config { + int port; + void init(); +}; +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + // Struct members default to public + expect(exportNames).toContain("Config"); + expect(exportNames).toContain("init"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Includes (imports) ---- + + describe("extractStructure - includes", () => { + it("extracts system includes (angle brackets)", () => { + const { tree, parser, root } = parse(` +#include +#include +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("iostream"); + expect(result.imports[0].specifiers).toEqual(["iostream"]); + expect(result.imports[1].source).toBe("vector"); + + tree.delete(); + parser.delete(); + }); + + it("extracts local includes (quoted)", () => { + const { tree, parser, root } = parse(` +#include "config.h" +#include "utils/helper.h" +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("config.h"); + expect(result.imports[0].specifiers).toEqual(["config.h"]); + expect(result.imports[1].source).toBe("utils/helper.h"); + + tree.delete(); + parser.delete(); + }); + + it("reports correct import line numbers", () => { + const { tree, parser, root } = parse(` +#include +#include "config.h" +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(2); + expect(result.imports[0].lineNumber).toBe(2); + expect(result.imports[1].lineNumber).toBe(3); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Namespaces ---- + + describe("extractStructure - namespaces", () => { + it("extracts functions inside namespaces", () => { + const { tree, parser, root } = parse(` +namespace utils { + int add(int a, int b) { + return a + b; + } + + void log(const char* msg) {} +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(2); + const names = result.functions.map((f) => f.name); + expect(names).toContain("add"); + expect(names).toContain("log"); + + tree.delete(); + parser.delete(); + }); + + it("extracts classes inside namespaces", () => { + const { tree, parser, root } = parse(` +namespace models { + class User { + public: + std::string name; + int id; + }; +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("User"); + expect(result.classes[0].properties).toEqual(["name", "id"]); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Out-of-class method definitions ---- + + describe("extractStructure - out-of-class methods", () => { + it("associates out-of-class method with its class", () => { + const { tree, parser, root } = parse(` +class Server { +public: + void start(); +}; + +void Server::start() { + // implementation +} +`); + const result = extractor.extractStructure(root); + + // The class should have start as a method (from both declaration and definition) + expect(result.classes[0].methods).toContain("start"); + + // The out-of-class definition should appear in functions + const startFn = result.functions.find((f) => f.name === "start"); + expect(startFn).toBeDefined(); + expect(startFn!.returnType).toBe("void"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Exports ---- + + describe("extractStructure - exports", () => { + it("exports non-static functions and not static ones", () => { + const { tree, parser, root } = parse(` +int public_fn(int x) { return x; } + +static void private_fn() {} +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("public_fn"); + expect(exportNames).not.toContain("private_fn"); + + tree.delete(); + parser.delete(); + }); + + it("reports correct export line numbers", () => { + const { tree, parser, root } = parse(` +struct Point { + int x; + int y; +}; + +int compute(int n) { return n * 2; } +`); + const result = extractor.extractStructure(root); + + const pointExport = result.exports.find((e) => e.name === "Point"); + expect(pointExport?.lineNumber).toBe(2); + + const computeExport = result.exports.find((e) => e.name === "compute"); + expect(computeExport?.lineNumber).toBe(7); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Call Graph ---- + + describe("extractCallGraph", () => { + it("extracts simple function calls", () => { + const { tree, parser, root } = parse(` +void helper(int x) {} + +int main() { + helper(42); +} +`); + const result = extractor.extractCallGraph(root); + + const mainCalls = result.filter((e) => e.caller === "main"); + expect(mainCalls.some((e) => e.callee === "helper")).toBe(true); + + tree.delete(); + parser.delete(); + }); + + it("extracts multiple calls from one function", () => { + const { tree, parser, root } = parse(` +void foo() {} +void bar() {} + +int main() { + foo(); + bar(); +} +`); + const result = extractor.extractCallGraph(root); + + const mainCalls = result.filter((e) => e.caller === "main"); + expect(mainCalls).toHaveLength(2); + expect(mainCalls.some((e) => e.callee === "foo")).toBe(true); + expect(mainCalls.some((e) => e.callee === "bar")).toBe(true); + + tree.delete(); + parser.delete(); + }); + + it("extracts calls inside namespace functions", () => { + const { tree, parser, root } = parse(` +int baz(int x) { return x; } + +namespace ns { + void inner() { + baz(42); + } +} +`); + const result = extractor.extractCallGraph(root); + + expect(result.some((e) => e.caller === "inner" && e.callee === "baz")).toBe(true); + + tree.delete(); + parser.delete(); + }); + + it("reports correct line numbers for calls", () => { + const { tree, parser, root } = parse(` +int main() { + foo(); + bar(); +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(2); + expect(result[0].lineNumber).toBe(3); + expect(result[1].lineNumber).toBe(4); + + tree.delete(); + parser.delete(); + }); + + it("ignores calls outside of functions (no caller)", () => { + const { tree, parser, root } = parse(` +int x = compute(); +`); + const result = extractor.extractCallGraph(root); + + // Top-level initializers have no enclosing function + expect(result).toHaveLength(0); + + tree.delete(); + parser.delete(); + }); + + it("tracks member function calls (field_expression)", () => { + const { tree, parser, root } = parse(` +void process() { + obj.method(); +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(1); + expect(result[0].caller).toBe("process"); + expect(result[0].callee).toBe("method"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Comprehensive C++ test ---- + + describe("comprehensive C++ file", () => { + it("handles the full C++ test scenario from the spec", () => { + const { tree, parser, root } = parse(`#include +#include "config.h" + +class Server { +public: + std::string host; + int port; + + void start(); + int getPort() { return port; } +}; + +void Server::start() { + std::cout << "starting" << std::endl; +} + +namespace utils { + int add(int a, int b) { + return a + b; + } +} +`); + const result = extractor.extractStructure(root); + + // Imports: 2 includes + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("iostream"); + expect(result.imports[1].source).toBe("config.h"); + + // Classes: Server + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Server"); + expect(result.classes[0].properties).toEqual(["host", "port"]); + expect(result.classes[0].methods).toContain("start"); + expect(result.classes[0].methods).toContain("getPort"); + + // Functions: getPort (inline), start (out-of-class), add (namespace) + expect(result.functions).toHaveLength(3); + const fnNames = result.functions.map((f) => f.name).sort(); + expect(fnNames).toEqual(["add", "getPort", "start"]); + + // add() params + const addFn = result.functions.find((f) => f.name === "add"); + expect(addFn?.params).toEqual(["a", "b"]); + expect(addFn?.returnType).toBe("int"); + + // getPort() inline + const getPortFn = result.functions.find((f) => f.name === "getPort"); + expect(getPortFn?.params).toEqual([]); + expect(getPortFn?.returnType).toBe("int"); + + // Exports: Server, start, getPort, add (all non-static/public) + const exportNames = result.exports.map((e) => e.name).sort(); + expect(exportNames).toContain("Server"); + expect(exportNames).toContain("start"); + expect(exportNames).toContain("getPort"); + expect(exportNames).toContain("add"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Comprehensive pure C test ---- + + describe("comprehensive pure C file", () => { + it("handles pure C code with structs and functions", () => { + const { tree, parser, root } = parse(`#include +#include "helper.h" + +struct Point { + int x; + int y; +}; + +void print_point(struct Point* p) { + printf("(%d, %d)", p->x, p->y); +} + +int main() { + struct Point p = {1, 2}; + print_point(&p); + return 0; +} +`); + const result = extractor.extractStructure(root); + + // Imports: 2 includes + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("stdio.h"); + expect(result.imports[0].specifiers).toEqual(["stdio.h"]); + expect(result.imports[1].source).toBe("helper.h"); + + // Classes: Point (struct mapped to class) + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Point"); + expect(result.classes[0].properties).toEqual(["x", "y"]); + expect(result.classes[0].methods).toEqual([]); + + // Functions: print_point and main + expect(result.functions).toHaveLength(2); + const fnNames = result.functions.map((f) => f.name).sort(); + expect(fnNames).toEqual(["main", "print_point"]); + + // print_point params + const printFn = result.functions.find((f) => f.name === "print_point"); + expect(printFn?.params).toEqual(["p"]); + expect(printFn?.returnType).toBe("void"); + + // main params + const mainFn = result.functions.find((f) => f.name === "main"); + expect(mainFn?.params).toEqual([]); + expect(mainFn?.returnType).toBe("int"); + + // Exports: non-static functions + struct name + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Point"); + expect(exportNames).toContain("print_point"); + expect(exportNames).toContain("main"); + + // Call graph + const calls = extractor.extractCallGraph(root); + + // print_point calls printf + const printCalls = calls.filter((e) => e.caller === "print_point"); + expect(printCalls.some((e) => e.callee === "printf")).toBe(true); + + // main calls print_point + const mainCalls = calls.filter((e) => e.caller === "main"); + expect(mainCalls.some((e) => e.callee === "print_point")).toBe(true); + + tree.delete(); + parser.delete(); + }); + + it("handles pure C code without any classes or structs", () => { + const { tree, parser, root } = parse(` +#include + +int factorial(int n) { + if (n <= 1) return 1; + return n * factorial(n - 1); +} + +int main() { + int result = factorial(5); + return 0; +} +`); + const result = extractor.extractStructure(root); + + // No classes in pure C without structs + expect(result.classes).toHaveLength(0); + + // Functions + expect(result.functions).toHaveLength(2); + expect(result.functions[0].name).toBe("factorial"); + expect(result.functions[0].params).toEqual(["n"]); + expect(result.functions[1].name).toBe("main"); + + // Call graph: factorial is recursive, main calls factorial + const calls = extractor.extractCallGraph(root); + expect(calls.some((e) => e.caller === "factorial" && e.callee === "factorial")).toBe(true); + expect(calls.some((e) => e.caller === "main" && e.callee === "factorial")).toBe(true); + + tree.delete(); + parser.delete(); + }); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/cpp-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/cpp-extractor.ts new file mode 100644 index 0000000..48b151e --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/cpp-extractor.ts @@ -0,0 +1,501 @@ +import type { StructuralAnalysis, CallGraphEntry } from "../../types.js"; +import type { LanguageExtractor, TreeSitterNode } from "./types.js"; +import { findChild, findChildren } from "./base-extractor.js"; + +/** + * Recursively unwrap nested declarators (pointer_declarator, reference_declarator, + * array_declarator) to find the leaf identifier name. + * + * C/C++ parameter declarators can be deeply nested: + * `char** pp` => pointer_declarator -> pointer_declarator -> identifier("pp") + * `const std::string& ref` => reference_declarator -> identifier("ref") + * `int arr[]` => array_declarator -> identifier("arr") + */ +function unwrapDeclaratorName(node: TreeSitterNode): string | null { + if (node.type === "identifier" || node.type === "field_identifier") { + return node.text; + } + // Dig into the nested declarator field + const inner = node.childForFieldName("declarator"); + if (inner) { + return unwrapDeclaratorName(inner); + } + // Fallback: look for direct identifier/field_identifier child + const id = findChild(node, "identifier") ?? findChild(node, "field_identifier"); + return id ? id.text : null; +} + +/** + * Extract the function/method name from a function_declarator node. + * + * The declarator field can be: + * - `identifier` for free functions: `int baz(int y)` + * - `field_identifier` for in-class declarations/definitions: `void start();` + * - `qualified_identifier` for out-of-class definitions: `void Server::start()` + * + * For qualified_identifier, we extract just the final name (e.g., "start"), + * but also return the qualifier (e.g., "Server") to associate methods with classes. + */ +function extractFuncDeclName( + funcDecl: TreeSitterNode, +): { name: string; qualifier: string | null } | null { + const declNode = funcDecl.childForFieldName("declarator"); + if (!declNode) return null; + + if (declNode.type === "identifier" || declNode.type === "field_identifier") { + return { name: declNode.text, qualifier: null }; + } + + if (declNode.type === "qualified_identifier") { + const nameNode = declNode.childForFieldName("name"); + // The qualifier is the namespace_identifier before :: + const nsNode = findChild(declNode, "namespace_identifier"); + return { + name: nameNode ? nameNode.text : declNode.text, + qualifier: nsNode ? nsNode.text : null, + }; + } + + return { name: declNode.text, qualifier: null }; +} + +/** + * Extract parameter names from a parameter_list node. + * + * Each parameter_declaration has a `declarator` field which may be an identifier, + * pointer_declarator, reference_declarator, or array_declarator. We recursively + * unwrap to find the actual name. + */ +function extractParams(paramsNode: TreeSitterNode | null): string[] { + if (!paramsNode) return []; + const params: string[] = []; + + const decls = findChildren(paramsNode, "parameter_declaration"); + for (const decl of decls) { + const declNode = decl.childForFieldName("declarator"); + if (declNode) { + const name = unwrapDeclaratorName(declNode); + if (name) { + params.push(name); + } + } + } + + return params; +} + +/** + * Extract the return type text from a function_definition node. + * + * The return type is the `type` named field on function_definition. + * Can be primitive_type, qualified_identifier, type_identifier, etc. + */ +function extractReturnType(node: TreeSitterNode): string | undefined { + const typeNode = node.childForFieldName("type"); + if (typeNode) { + return typeNode.text; + } + return undefined; +} + +/** + * Check if a function_definition has a `storage_class_specifier` child with "static". + */ +function isStatic(node: TreeSitterNode): boolean { + const storage = findChild(node, "storage_class_specifier"); + return storage !== null && storage.text === "static"; +} + +/** + * C/C++ extractor for tree-sitter structural analysis and call graph extraction. + * + * Handles: + * - Free functions (function_definition) + * - Classes (class_specifier) with methods, properties, and access specifiers + * - Structs (struct_specifier) with fields + * - #include directives mapped to imports + * - Namespaces (namespace_definition) with recursive traversal + * - Out-of-class method definitions (e.g., void Server::start()) + * - Call graph extraction from call_expression nodes + * + * C/C++ has no formal export syntax. Non-static top-level functions and + * public class/struct members are treated as exports. + */ +export class CppExtractor implements LanguageExtractor { + readonly languageIds = ["cpp"]; + + extractStructure(rootNode: TreeSitterNode): StructuralAnalysis { + const functions: StructuralAnalysis["functions"] = []; + const classes: StructuralAnalysis["classes"] = []; + const imports: StructuralAnalysis["imports"] = []; + const exports: StructuralAnalysis["exports"] = []; + + // Track methods associated with classes via out-of-class definitions + const methodsByClass = new Map(); + + this.walkTopLevel(rootNode, functions, classes, imports, exports, methodsByClass); + + // Attach out-of-class methods to their corresponding classes + for (const cls of classes) { + const methods = methodsByClass.get(cls.name); + if (methods) { + for (const m of methods) { + if (!cls.methods.includes(m)) { + cls.methods.push(m); + } + } + } + } + + return { functions, classes, imports, exports }; + } + + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] { + const entries: CallGraphEntry[] = []; + const functionStack: string[] = []; + + const walkForCalls = (node: TreeSitterNode) => { + let pushedName = false; + + // Track entering function_definition + if (node.type === "function_definition") { + const name = this.extractFunctionName(node); + if (name) { + functionStack.push(name); + pushedName = true; + } + } + + // Extract call_expression nodes + if (node.type === "call_expression") { + if (functionStack.length > 0) { + const callee = this.extractCalleeName(node); + if (callee) { + entries.push({ + caller: functionStack[functionStack.length - 1], + callee, + lineNumber: node.startPosition.row + 1, + }); + } + } + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) walkForCalls(child); + } + + if (pushedName) { + functionStack.pop(); + } + }; + + walkForCalls(rootNode); + + return entries; + } + + // ---- Private helpers ---- + + /** + * Walk top-level declarations. Recurses into namespace_definition bodies + * to find nested declarations. + */ + private walkTopLevel( + parentNode: TreeSitterNode, + functions: StructuralAnalysis["functions"], + classes: StructuralAnalysis["classes"], + imports: StructuralAnalysis["imports"], + exports: StructuralAnalysis["exports"], + methodsByClass: Map, + ): void { + for (let i = 0; i < parentNode.childCount; i++) { + const node = parentNode.child(i); + if (!node) continue; + + switch (node.type) { + case "preproc_include": + this.extractInclude(node, imports); + break; + + case "class_specifier": + this.extractClassOrStruct(node, "class", classes, functions, exports); + break; + + case "struct_specifier": + this.extractClassOrStruct(node, "struct", classes, functions, exports); + break; + + case "function_definition": + this.extractFunctionDef(node, functions, exports, methodsByClass); + break; + + case "namespace_definition": { + // Recurse into namespace body (declaration_list) + const body = findChild(node, "declaration_list"); + if (body) { + this.walkTopLevel(body, functions, classes, imports, exports, methodsByClass); + } + break; + } + + case "declaration": { + // A top-level ";" terminated statement — could be a class/struct with a trailing ; + // e.g., `class Foo { ... };` parses the class_specifier as a child of a + // declaration in some contexts. Check for nested class/struct specifiers. + const innerClass = findChild(node, "class_specifier"); + if (innerClass) { + this.extractClassOrStruct(innerClass, "class", classes, functions, exports); + } + const innerStruct = findChild(node, "struct_specifier"); + if (innerStruct) { + this.extractClassOrStruct(innerStruct, "struct", classes, functions, exports); + } + break; + } + } + } + } + + /** + * Extract the simple function name from a function_definition. + * For qualified names (e.g., Server::start), returns just the method name. + */ + private extractFunctionName(node: TreeSitterNode): string | null { + const declNode = node.childForFieldName("declarator"); + if (!declNode || declNode.type !== "function_declarator") return null; + + const info = extractFuncDeclName(declNode); + return info ? info.name : null; + } + + /** + * Extract #include directives and map them to the imports array. + * + * `preproc_include` has a `path` field that is either: + * - `system_lib_string` for angle-bracket includes: `` + * - `string_literal` for quoted includes: `"myfile.h"` + */ + private extractInclude( + node: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + const pathNode = node.childForFieldName("path"); + if (!pathNode) return; + + let source: string; + if (pathNode.type === "system_lib_string") { + // Strip angle brackets: -> iostream + source = pathNode.text.replace(/^<|>$/g, ""); + } else if (pathNode.type === "string_literal") { + // Extract content from string: "myfile.h" -> myfile.h + const content = findChild(pathNode, "string_content"); + source = content ? content.text : pathNode.text.replace(/^"|"$/g, ""); + } else { + source = pathNode.text; + } + + imports.push({ + source, + specifiers: [source], + lineNumber: node.startPosition.row + 1, + }); + } + + /** + * Extract class_specifier or struct_specifier into the classes array. + * + * Processes: + * - Properties (field_declaration without function_declarator) + * - Method declarations (field_declaration with function_declarator) + * - Method definitions (function_definition inside the class body) + * - Access specifiers (public/private/protected) + * + * Public members of classes and all members of structs (default public) + * are treated as exports. + */ + private extractClassOrStruct( + node: TreeSitterNode, + kind: "class" | "struct", + classes: StructuralAnalysis["classes"], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const className = nameNode.text; + const methods: string[] = []; + const properties: string[] = []; + + const body = node.childForFieldName("body"); + if (body && body.type === "field_declaration_list") { + // Default access: public for struct, private for class + let currentAccess = kind === "struct" ? "public" : "private"; + + for (let j = 0; j < body.childCount; j++) { + const member = body.child(j); + if (!member) continue; + + if (member.type === "access_specifier") { + // Update current access level + const specChild = member.child(0); + if (specChild) { + currentAccess = specChild.text; + } + continue; + } + + if (member.type === "field_declaration") { + const declNode = member.childForFieldName("declarator"); + if (declNode && declNode.type === "function_declarator") { + // Method declaration (no body) + const info = extractFuncDeclName(declNode); + if (info) { + methods.push(info.name); + if (currentAccess === "public") { + exports.push({ + name: info.name, + lineNumber: member.startPosition.row + 1, + }); + } + } + } else if (declNode) { + // Property (field_identifier or other declarator) + const name = unwrapDeclaratorName(declNode); + if (name) { + properties.push(name); + } + } + } + + if (member.type === "function_definition") { + // Inline method definition + const funcDecl = member.childForFieldName("declarator"); + if (funcDecl && funcDecl.type === "function_declarator") { + const info = extractFuncDeclName(funcDecl); + if (info) { + methods.push(info.name); + + // Also add to functions list with params/return type + const paramsNode = funcDecl.childForFieldName("parameters"); + functions.push({ + name: info.name, + lineRange: [ + member.startPosition.row + 1, + member.endPosition.row + 1, + ], + params: extractParams(paramsNode), + returnType: extractReturnType(member), + }); + + if (currentAccess === "public") { + exports.push({ + name: info.name, + lineNumber: member.startPosition.row + 1, + }); + } + } + } + } + } + } + + classes.push({ + name: className, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + methods, + properties, + }); + + // The class/struct name itself is an export (non-anonymous types are always exported in C/C++ headers) + exports.push({ + name: className, + lineNumber: node.startPosition.row + 1, + }); + } + + /** + * Extract a free function or out-of-class method definition. + * + * For qualified names (e.g., `void Server::start()`), the method is: + * - Added to the functions array + * - Tracked in methodsByClass for later association with the class + * - Exported if non-static + * + * Static functions are NOT exported. + */ + private extractFunctionDef( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + methodsByClass: Map, + ): void { + const funcDecl = node.childForFieldName("declarator"); + if (!funcDecl || funcDecl.type !== "function_declarator") return; + + const info = extractFuncDeclName(funcDecl); + if (!info) return; + + const paramsNode = funcDecl.childForFieldName("parameters"); + const params = extractParams(paramsNode); + const returnType = extractReturnType(node); + + functions.push({ + name: info.name, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + params, + returnType, + }); + + // Track out-of-class method definitions (e.g., void Server::start()) + if (info.qualifier) { + if (!methodsByClass.has(info.qualifier)) { + methodsByClass.set(info.qualifier, []); + } + methodsByClass.get(info.qualifier)!.push(info.name); + } + + // Non-static top-level functions are exports + if (!isStatic(node)) { + exports.push({ + name: info.name, + lineNumber: node.startPosition.row + 1, + }); + } + } + + /** + * Extract the callee name from a call_expression. + * + * Handles: + * - Plain function call: `printf(...)` -> "printf" + * - Member call via field_expression: `p->method()` -> "p->method" + * - Scoped call: `std::cout << ...` -> qualified name text + */ + private extractCalleeName(callNode: TreeSitterNode): string | null { + const funcNode = callNode.child(0); + if (!funcNode) return null; + + if (funcNode.type === "identifier") { + return funcNode.text; + } + + if (funcNode.type === "field_expression") { + const field = funcNode.childForFieldName("field"); + return field ? field.text : funcNode.text; + } + + if (funcNode.type === "qualified_identifier") { + return funcNode.text; + } + + return funcNode.text; + } +} From 3e4f7b6c02cab0b52c0b20cfdcb2e7bb27f00109 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 19:19:24 +0800 Subject: [PATCH 14/18] feat: add CSharpExtractor for tree-sitter C# structural analysis Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/csharp-extractor.test.ts | 665 ++++++++++++++++++ .../plugins/extractors/csharp-extractor.ts | 524 ++++++++++++++ 2 files changed, 1189 insertions(+) create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/csharp-extractor.test.ts create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/csharp-extractor.ts diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/csharp-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/csharp-extractor.test.ts new file mode 100644 index 0000000..e1534b8 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/csharp-extractor.test.ts @@ -0,0 +1,665 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { createRequire } from "node:module"; +import { CSharpExtractor } from "../csharp-extractor.js"; + +const require = createRequire(import.meta.url); + +// Load tree-sitter + C# grammar once +let Parser: any; +let Language: any; +let csharpLang: any; + +beforeAll(async () => { + const mod = await import("web-tree-sitter"); + Parser = mod.Parser; + Language = mod.Language; + await Parser.init(); + const wasmPath = require.resolve( + "tree-sitter-c-sharp/tree-sitter-c_sharp.wasm", + ); + csharpLang = await Language.load(wasmPath); +}); + +function parse(code: string) { + const parser = new Parser(); + parser.setLanguage(csharpLang); + const tree = parser.parse(code); + const root = tree.rootNode; + return { tree, parser, root }; +} + +describe("CSharpExtractor", () => { + const extractor = new CSharpExtractor(); + + it("has correct languageIds", () => { + expect(extractor.languageIds).toEqual(["csharp"]); + }); + + // ---- Methods/Constructors (mapped to functions) ---- + + describe("extractStructure - functions (methods & constructors)", () => { + it("extracts methods with params and return types", () => { + const { tree, parser, root } = parse(`namespace App { + public class Foo { + public string GetName(int id) { + return ""; + } + private void Process(string data, int count) { + } + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(2); + + expect(result.functions[0].name).toBe("GetName"); + expect(result.functions[0].params).toEqual(["id"]); + expect(result.functions[0].returnType).toBe("string"); + + expect(result.functions[1].name).toBe("Process"); + expect(result.functions[1].params).toEqual(["data", "count"]); + expect(result.functions[1].returnType).toBe("void"); + + tree.delete(); + parser.delete(); + }); + + it("extracts constructors", () => { + const { tree, parser, root } = parse(`namespace App { + public class Foo { + public Foo(string name, int value) { + this.name = name; + } + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("Foo"); + expect(result.functions[0].params).toEqual(["name", "value"]); + expect(result.functions[0].returnType).toBeUndefined(); + + tree.delete(); + parser.delete(); + }); + + it("extracts methods with no params", () => { + const { tree, parser, root } = parse(`namespace App { + public class Foo { + public void Run() {} + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("Run"); + expect(result.functions[0].params).toEqual([]); + expect(result.functions[0].returnType).toBe("void"); + + tree.delete(); + parser.delete(); + }); + + it("extracts methods with generic return types", () => { + const { tree, parser, root } = parse(`namespace App { + public class Foo { + public List GetItems() { + return null; + } + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("GetItems"); + expect(result.functions[0].returnType).toBe("List"); + + tree.delete(); + parser.delete(); + }); + + it("reports correct line ranges for multi-line methods", () => { + const { tree, parser, root } = parse(`namespace App { + public class Foo { + public int Calculate( + int a, + int b + ) { + int result = a + b; + return result; + } + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.functions).toHaveLength(1); + expect(result.functions[0].lineRange[0]).toBe(3); + expect(result.functions[0].lineRange[1]).toBe(9); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Classes ---- + + describe("extractStructure - classes", () => { + it("extracts class with methods, properties, and fields", () => { + const { tree, parser, root } = parse(`namespace App { + public class Server { + private string _host; + private int _port; + public string Address { get; set; } + public void Start() {} + public void Stop() {} + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Server"); + expect(result.classes[0].properties).toEqual(["_host", "_port", "Address"]); + expect(result.classes[0].methods).toEqual(["Start", "Stop"]); + expect(result.classes[0].lineRange[0]).toBe(2); + + tree.delete(); + parser.delete(); + }); + + it("extracts empty class", () => { + const { tree, parser, root } = parse(`namespace App { + public class Empty { + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Empty"); + expect(result.classes[0].properties).toEqual([]); + expect(result.classes[0].methods).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("includes constructors in methods list", () => { + const { tree, parser, root } = parse(`namespace App { + public class Foo { + public Foo() {} + public void Run() {} + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes[0].methods).toEqual(["Foo", "Run"]); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Interfaces ---- + + describe("extractStructure - interfaces", () => { + it("extracts interface with method signatures", () => { + const { tree, parser, root } = parse(`namespace App { + interface IRepository { + List FindAll(); + User FindById(int id); + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("IRepository"); + expect(result.classes[0].methods).toEqual(["FindAll", "FindById"]); + expect(result.classes[0].properties).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("extracts empty interface", () => { + const { tree, parser, root } = parse(`namespace App { + interface IMarker { + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("IMarker"); + expect(result.classes[0].methods).toEqual([]); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Imports (using directives) ---- + + describe("extractStructure - imports", () => { + it("extracts simple using directives", () => { + const { tree, parser, root } = parse(`using System; +namespace App { + public class Foo {} +} +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("System"); + expect(result.imports[0].specifiers).toEqual(["System"]); + expect(result.imports[0].lineNumber).toBe(1); + + tree.delete(); + parser.delete(); + }); + + it("extracts qualified using directives", () => { + const { tree, parser, root } = parse(`using System; +using System.Collections.Generic; +namespace App { + public class Foo {} +} +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("System"); + expect(result.imports[0].specifiers).toEqual(["System"]); + expect(result.imports[0].lineNumber).toBe(1); + expect(result.imports[1].source).toBe("System.Collections.Generic"); + expect(result.imports[1].specifiers).toEqual(["Generic"]); + expect(result.imports[1].lineNumber).toBe(2); + + tree.delete(); + parser.delete(); + }); + + it("reports correct import line numbers with gaps", () => { + const { tree, parser, root } = parse(`using System; + +using System.Linq; +namespace App { + public class Foo {} +} +`); + const result = extractor.extractStructure(root); + + expect(result.imports[0].lineNumber).toBe(1); + expect(result.imports[1].lineNumber).toBe(3); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Exports ---- + + describe("extractStructure - exports", () => { + it("exports public class, methods, constructor, and properties", () => { + const { tree, parser, root } = parse(`namespace App { + public class UserService { + private string _name; + public int MaxRetries { get; set; } + public UserService(string name) { + _name = name; + } + public void Start() {} + private void Helper() {} + } +} +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("UserService"); // class + // Constructor is also named UserService + const userServiceExports = result.exports.filter( + (e) => e.name === "UserService", + ); + expect(userServiceExports.length).toBe(2); // class + constructor + expect(exportNames).toContain("MaxRetries"); // public property + expect(exportNames).toContain("Start"); + expect(exportNames).not.toContain("Helper"); + expect(exportNames).not.toContain("_name"); // private field + + tree.delete(); + parser.delete(); + }); + + it("does not export non-public classes", () => { + const { tree, parser, root } = parse(`namespace App { + class Internal { + void Run() {} + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.exports).toHaveLength(0); + + tree.delete(); + parser.delete(); + }); + + it("exports public fields", () => { + const { tree, parser, root } = parse(`namespace App { + public class Config { + public string ApiKey; + private int _retries; + } +} +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Config"); + expect(exportNames).toContain("ApiKey"); + expect(exportNames).not.toContain("_retries"); + + tree.delete(); + parser.delete(); + }); + + it("exports public interface", () => { + const { tree, parser, root } = parse(`namespace App { + public interface IRepository { + void Save(); + } +} +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("IRepository"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Call Graph ---- + + describe("extractCallGraph", () => { + it("extracts simple method calls", () => { + const { tree, parser, root } = parse(`namespace App { + public class Foo { + public void Process(int data) { + Transform(data); + Format(data); + } + } +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(2); + expect(result[0].caller).toBe("Process"); + expect(result[0].callee).toBe("Transform"); + expect(result[1].caller).toBe("Process"); + expect(result[1].callee).toBe("Format"); + + tree.delete(); + parser.delete(); + }); + + it("extracts qualified method calls (e.g. Console.WriteLine)", () => { + const { tree, parser, root } = parse(`namespace App { + public class Foo { + private void Log(string message) { + Console.WriteLine(message); + } + } +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(1); + expect(result[0].caller).toBe("Log"); + expect(result[0].callee).toBe("Console.WriteLine"); + + tree.delete(); + parser.delete(); + }); + + it("extracts object creation expressions", () => { + const { tree, parser, root } = parse(`namespace App { + public class Foo { + public void Create() { + var b = new Bar(); + } + } +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(1); + expect(result[0].caller).toBe("Create"); + expect(result[0].callee).toBe("new Bar"); + + tree.delete(); + parser.delete(); + }); + + it("tracks correct caller for constructors", () => { + const { tree, parser, root } = parse(`namespace App { + public class Foo { + public Foo() { + Init(); + } + } +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(1); + expect(result[0].caller).toBe("Foo"); + expect(result[0].callee).toBe("Init"); + + tree.delete(); + parser.delete(); + }); + + it("reports correct line numbers for calls", () => { + const { tree, parser, root } = parse(`namespace App { + public class Foo { + public void Run() { + Foo(); + Bar(); + } + } +} +`); + const result = extractor.extractCallGraph(root); + + expect(result).toHaveLength(2); + expect(result[0].lineNumber).toBe(4); + expect(result[1].lineNumber).toBe(5); + + tree.delete(); + parser.delete(); + }); + + it("ignores calls outside methods (no caller)", () => { + const { tree, parser, root } = parse(`namespace App { + public class Foo { + private string _value = String.Empty; + } +} +`); + const result = extractor.extractCallGraph(root); + + // No enclosing method, so these are skipped + expect(result).toHaveLength(0); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Namespace handling ---- + + describe("namespace handling", () => { + it("extracts declarations from block-scoped namespace", () => { + const { tree, parser, root } = parse(`namespace App.Services { + public class Svc { + public void Run() {} + } +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Svc"); + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("Run"); + + tree.delete(); + parser.delete(); + }); + + it("extracts declarations alongside file-scoped namespace", () => { + const { tree, parser, root } = parse(`namespace App.Services; + +public class Svc { + public void Run() {} +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Svc"); + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("Run"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Comprehensive ---- + + describe("comprehensive C# file", () => { + it("handles a realistic C# module", () => { + const { tree, parser, root } = parse(`using System; +using System.Collections.Generic; + +namespace App.Services +{ + public class UserService + { + private string _name; + public int MaxRetries { get; set; } + + public UserService(string name) + { + _name = name; + } + + public List GetUsers(int limit) + { + return FetchFromDb(limit); + } + + private void Log(string message) + { + Console.WriteLine(message); + } + } + + public interface IRepository + { + List FindAll(); + User FindById(int id); + } +} +`); + const result = extractor.extractStructure(root); + + // Functions: UserService (constructor), GetUsers, Log + expect(result.functions).toHaveLength(3); + expect(result.functions.map((f) => f.name).sort()).toEqual( + ["GetUsers", "Log", "UserService"].sort(), + ); + + // Constructor has params but no return type + const ctor = result.functions.find((f) => f.name === "UserService"); + expect(ctor?.params).toEqual(["name"]); + expect(ctor?.returnType).toBeUndefined(); + + // GetUsers has params and generic return type + const getUsers = result.functions.find((f) => f.name === "GetUsers"); + expect(getUsers?.params).toEqual(["limit"]); + expect(getUsers?.returnType).toBe("List"); + + // Log has params and void return type + const log = result.functions.find((f) => f.name === "Log"); + expect(log?.params).toEqual(["message"]); + expect(log?.returnType).toBe("void"); + + // Classes: UserService, IRepository + expect(result.classes).toHaveLength(2); + + const userService = result.classes.find( + (c) => c.name === "UserService", + ); + expect(userService).toBeDefined(); + expect(userService!.methods.sort()).toEqual( + ["GetUsers", "Log", "UserService"].sort(), + ); + expect(userService!.properties.sort()).toEqual( + ["MaxRetries", "_name"].sort(), + ); + + const repository = result.classes.find( + (c) => c.name === "IRepository", + ); + expect(repository).toBeDefined(); + expect(repository!.methods).toEqual(["FindAll", "FindById"]); + expect(repository!.properties).toEqual([]); + + // Imports: 2 (System, System.Collections.Generic) + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("System"); + expect(result.imports[0].specifiers).toEqual(["System"]); + expect(result.imports[1].source).toBe("System.Collections.Generic"); + expect(result.imports[1].specifiers).toEqual(["Generic"]); + + // Exports: UserService (class + constructor), MaxRetries, GetUsers, IRepository + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("UserService"); + expect(exportNames).toContain("GetUsers"); + expect(exportNames).toContain("MaxRetries"); + expect(exportNames).toContain("IRepository"); + expect(exportNames).not.toContain("Log"); // private + expect(exportNames).not.toContain("_name"); // private field + + // Call graph + const calls = extractor.extractCallGraph(root); + + const getUsersCalls = calls.filter((e) => e.caller === "GetUsers"); + expect(getUsersCalls.some((e) => e.callee === "FetchFromDb")).toBe( + true, + ); + + const logCalls = calls.filter((e) => e.caller === "Log"); + expect( + logCalls.some((e) => e.callee === "Console.WriteLine"), + ).toBe(true); + + tree.delete(); + parser.delete(); + }); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/csharp-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/csharp-extractor.ts new file mode 100644 index 0000000..19b77b5 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/csharp-extractor.ts @@ -0,0 +1,524 @@ +import type { StructuralAnalysis, CallGraphEntry } from "../../types.js"; +import type { LanguageExtractor, TreeSitterNode } from "./types.js"; +import { findChild, findChildren } from "./base-extractor.js"; + +/** + * Extract parameter names from a C# `parameter_list` node. + * + * Each `parameter` child has a `name` field (identifier) and a `type` field. + */ +function extractParams(paramsNode: TreeSitterNode | null): string[] { + if (!paramsNode) return []; + const params: string[] = []; + + const paramNodes = findChildren(paramsNode, "parameter"); + for (const param of paramNodes) { + const nameNode = param.childForFieldName("name"); + if (nameNode) { + params.push(nameNode.text); + } + } + + return params; +} + +/** + * Extract the return type text from a method_declaration node. + * + * In tree-sitter-c-sharp, the return type is the `returns` named field. + * It can be a predefined_type (void, int, string), generic_name (List), + * identifier, nullable_type, etc. + */ +function extractReturnType(node: TreeSitterNode): string | undefined { + const typeNode = node.childForFieldName("returns"); + if (!typeNode) return undefined; + return typeNode.text; +} + +/** + * Check if a C# declaration node has a specific modifier. + * + * Unlike Java (which has a single `modifiers` container), C# tree-sitter + * emits multiple separate `modifier` nodes as direct children of the + * declaration. Each modifier node contains a single keyword child + * (e.g., `public`, `private`, `static`). + */ +function hasModifier(node: TreeSitterNode, modifier: string): boolean { + const modifierNodes = findChildren(node, "modifier"); + for (const mod of modifierNodes) { + for (let i = 0; i < mod.childCount; i++) { + const child = mod.child(i); + if (child && child.text === modifier) return true; + } + } + return false; +} + +/** + * Extract the namespace source text from a using_directive. + * + * Handles both simple identifiers (`using System;`) and qualified names + * (`using System.Collections.Generic;`). For aliased usings like + * `using Alias = Some.Namespace;`, extracts the target namespace. + */ +function extractUsingSource(node: TreeSitterNode): string | null { + // Check for alias form: `using Alias = Some.Namespace;` + const hasEquals = findChild(node, "=") !== null; + + if (hasEquals) { + // The target namespace is the qualified_name after the `=` + const qualifiedName = findChild(node, "qualified_name"); + return qualifiedName ? qualifiedName.text : null; + } + + // Simple or qualified using + const qualifiedName = findChild(node, "qualified_name"); + if (qualifiedName) return qualifiedName.text; + + const identifier = findChild(node, "identifier"); + return identifier ? identifier.text : null; +} + +/** + * Get the last component of a dotted namespace path. + * e.g. "System.Collections.Generic" -> "Generic" + */ +function lastComponent(path: string): string { + const parts = path.split("."); + return parts[parts.length - 1]; +} + +/** + * Extract the callee name from an invocation_expression node. + * + * Handles: + * - Plain method call: `FetchFromDb(limit)` -> "FetchFromDb" + * (function field is an identifier) + * - Qualified call: `Console.WriteLine(msg)` -> "Console.WriteLine" + * (function field is a member_access_expression) + */ +function extractInvocationName(node: TreeSitterNode): string | null { + const funcNode = node.childForFieldName("function"); + if (!funcNode) return null; + return funcNode.text; +} + +/** + * C# extractor for tree-sitter structural analysis and call graph extraction. + * + * Handles classes, interfaces, methods, constructors, properties, fields, + * using directives, visibility-based exports, and call graphs for C# source code. + * + * C#-specific mapping decisions: + * - Classes and interfaces are mapped to the `classes` array. + * - Constructors are mapped to the `functions` array (named after the class). + * - Methods (including interface method signatures) are listed in the + * containing class/interface's `methods` array and also in the `functions` array. + * - Properties (e.g., `public string Name { get; set; }`) are extracted into + * the containing class's `properties` array alongside fields. + * - Exports are determined by the `public` modifier on classes, interfaces, + * methods, constructors, properties, and fields. + * - Namespaces: both block-scoped (`namespace Foo { ... }`) and file-scoped + * (`namespace Foo;`) are traversed to find declarations. + * - Using directives are mapped to imports, with the last dotted component + * as the specifier. + */ +export class CSharpExtractor implements LanguageExtractor { + readonly languageIds = ["csharp"]; + + extractStructure(rootNode: TreeSitterNode): StructuralAnalysis { + const functions: StructuralAnalysis["functions"] = []; + const classes: StructuralAnalysis["classes"] = []; + const imports: StructuralAnalysis["imports"] = []; + const exports: StructuralAnalysis["exports"] = []; + + this.walkTopLevel(rootNode, functions, classes, imports, exports); + + return { functions, classes, imports, exports }; + } + + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] { + const entries: CallGraphEntry[] = []; + const functionStack: string[] = []; + + const walkForCalls = (node: TreeSitterNode) => { + let pushedName = false; + + // Track entering method/constructor declarations + if ( + node.type === "method_declaration" || + node.type === "constructor_declaration" + ) { + const nameNode = node.childForFieldName("name"); + if (nameNode) { + functionStack.push(nameNode.text); + pushedName = true; + } + } + + // Extract method invocations: e.g. FetchFromDb(limit), Console.WriteLine(msg) + if (node.type === "invocation_expression") { + if (functionStack.length > 0) { + const callee = extractInvocationName(node); + if (callee) { + entries.push({ + caller: functionStack[functionStack.length - 1], + callee, + lineNumber: node.startPosition.row + 1, + }); + } + } + } + + // Extract object creation: e.g. new Foo() + if (node.type === "object_creation_expression") { + if (functionStack.length > 0) { + // The type is the child after `new` — can be identifier or generic_name + const typeNode = findChild(node, "identifier") ?? findChild(node, "generic_name"); + if (typeNode) { + entries.push({ + caller: functionStack[functionStack.length - 1], + callee: `new ${typeNode.text}`, + lineNumber: node.startPosition.row + 1, + }); + } + } + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) walkForCalls(child); + } + + if (pushedName) { + functionStack.pop(); + } + }; + + walkForCalls(rootNode); + + return entries; + } + + // ---- Private helpers ---- + + /** + * Walk the top-level nodes of a compilation_unit, recursing into + * namespace bodies to find declarations. + */ + private walkTopLevel( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + classes: StructuralAnalysis["classes"], + imports: StructuralAnalysis["imports"], + exports: StructuralAnalysis["exports"], + ): void { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child) continue; + + switch (child.type) { + case "using_directive": + this.extractUsing(child, imports); + break; + + case "namespace_declaration": + // Recurse into namespace body (declaration_list) + this.walkNamespaceBody(child, functions, classes, imports, exports); + break; + + case "file_scoped_namespace_declaration": + // File-scoped namespace: declarations are siblings at the root, + // not children of this node. Nothing to recurse into. + break; + + case "class_declaration": + this.extractClass(child, functions, classes, exports); + break; + + case "interface_declaration": + this.extractInterface(child, functions, classes, exports); + break; + } + } + } + + /** + * Walk into a namespace_declaration's body (declaration_list) to find + * classes, interfaces, and nested namespaces. + */ + private walkNamespaceBody( + nsNode: TreeSitterNode, + functions: StructuralAnalysis["functions"], + classes: StructuralAnalysis["classes"], + imports: StructuralAnalysis["imports"], + exports: StructuralAnalysis["exports"], + ): void { + const body = nsNode.childForFieldName("body"); + if (!body) return; + + for (let i = 0; i < body.childCount; i++) { + const child = body.child(i); + if (!child) continue; + + switch (child.type) { + case "class_declaration": + this.extractClass(child, functions, classes, exports); + break; + + case "interface_declaration": + this.extractInterface(child, functions, classes, exports); + break; + + case "namespace_declaration": + // Nested namespaces + this.walkNamespaceBody(child, functions, classes, imports, exports); + break; + } + } + } + + private extractUsing( + node: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + const source = extractUsingSource(node); + if (!source) return; + + imports.push({ + source, + specifiers: [lastComponent(source)], + lineNumber: node.startPosition.row + 1, + }); + } + + private extractClass( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + classes: StructuralAnalysis["classes"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const methods: string[] = []; + const properties: string[] = []; + + const body = node.childForFieldName("body"); + if (body) { + this.extractClassBodyMembers(body, methods, properties, functions, exports); + } + + classes.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + methods, + properties, + }); + + if (hasModifier(node, "public")) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractInterface( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + classes: StructuralAnalysis["classes"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const methods: string[] = []; + const properties: string[] = []; + + const body = node.childForFieldName("body"); + if (body) { + // Interface body contains method_declaration nodes (signatures without bodies) + const methodNodes = findChildren(body, "method_declaration"); + for (const methodNode of methodNodes) { + const methNameNode = methodNode.childForFieldName("name"); + if (methNameNode) { + methods.push(methNameNode.text); + } + } + + // Interface can contain property_declaration nodes + const propNodes = findChildren(body, "property_declaration"); + for (const propNode of propNodes) { + const propNameNode = propNode.childForFieldName("name"); + if (propNameNode) { + properties.push(propNameNode.text); + } + } + } + + classes.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + methods, + properties, + }); + + if (hasModifier(node, "public")) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + /** + * Extract methods, constructors, properties, and fields from a + * class declaration_list body. + */ + private extractClassBodyMembers( + body: TreeSitterNode, + methods: string[], + properties: string[], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + ): void { + for (let i = 0; i < body.childCount; i++) { + const child = body.child(i); + if (!child) continue; + + switch (child.type) { + case "method_declaration": + this.extractMethod(child, methods, functions, exports); + break; + + case "constructor_declaration": + this.extractConstructor(child, methods, functions, exports); + break; + + case "property_declaration": + this.extractProperty(child, properties, exports); + break; + + case "field_declaration": + this.extractField(child, properties, exports); + break; + } + } + } + + private extractMethod( + node: TreeSitterNode, + methods: string[], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const paramsNode = node.childForFieldName("parameters"); + const params = extractParams(paramsNode ?? null); + const returnType = extractReturnType(node); + + methods.push(nameNode.text); + + functions.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + params, + returnType, + }); + + if (hasModifier(node, "public")) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractConstructor( + node: TreeSitterNode, + methods: string[], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const paramsNode = node.childForFieldName("parameters"); + const params = extractParams(paramsNode ?? null); + + methods.push(nameNode.text); + + functions.push({ + name: nameNode.text, + lineRange: [ + node.startPosition.row + 1, + node.endPosition.row + 1, + ], + params, + // Constructors have no return type + }); + + if (hasModifier(node, "public")) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractProperty( + node: TreeSitterNode, + properties: string[], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + properties.push(nameNode.text); + + if (hasModifier(node, "public")) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + + private extractField( + node: TreeSitterNode, + properties: string[], + exports: StructuralAnalysis["exports"], + ): void { + // field_declaration -> variable_declaration -> variable_declarator(s) + const varDecl = findChild(node, "variable_declaration"); + if (!varDecl) return; + + const declarators = findChildren(varDecl, "variable_declarator"); + for (const decl of declarators) { + // variable_declarator's first child is the identifier + const nameNode = findChild(decl, "identifier"); + if (nameNode) { + properties.push(nameNode.text); + + if (hasModifier(node, "public")) { + exports.push({ + name: nameNode.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + } + } +} From 4ff04b251e3e0f097e329ce472546500bbcbfc49 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 19:20:42 +0800 Subject: [PATCH 15/18] feat: create extractor index with builtinExtractors, wire all 9 extractors into TreeSitterPlugin Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/src/plugins/extractors/index.ts | 34 +++++++++++++++++++ .../core/src/plugins/tree-sitter-plugin.ts | 8 +++-- 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/index.ts diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts new file mode 100644 index 0000000..f148c61 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts @@ -0,0 +1,34 @@ +export type { LanguageExtractor, TreeSitterNode } from "./types.js"; +export { traverse, getStringValue, findChild, findChildren, hasChildOfType } from "./base-extractor.js"; +export { TypeScriptExtractor } from "./typescript-extractor.js"; +export { PythonExtractor } from "./python-extractor.js"; +export { GoExtractor } from "./go-extractor.js"; +export { RustExtractor } from "./rust-extractor.js"; +export { JavaExtractor } from "./java-extractor.js"; +export { RubyExtractor } from "./ruby-extractor.js"; +export { PhpExtractor } from "./php-extractor.js"; +export { CppExtractor } from "./cpp-extractor.js"; +export { CSharpExtractor } from "./csharp-extractor.js"; + +import type { LanguageExtractor } from "./types.js"; +import { TypeScriptExtractor } from "./typescript-extractor.js"; +import { PythonExtractor } from "./python-extractor.js"; +import { GoExtractor } from "./go-extractor.js"; +import { RustExtractor } from "./rust-extractor.js"; +import { JavaExtractor } from "./java-extractor.js"; +import { RubyExtractor } from "./ruby-extractor.js"; +import { PhpExtractor } from "./php-extractor.js"; +import { CppExtractor } from "./cpp-extractor.js"; +import { CSharpExtractor } from "./csharp-extractor.js"; + +export const builtinExtractors: LanguageExtractor[] = [ + new TypeScriptExtractor(), + new PythonExtractor(), + new GoExtractor(), + new RustExtractor(), + new JavaExtractor(), + new RubyExtractor(), + new PhpExtractor(), + new CppExtractor(), + new CSharpExtractor(), +]; 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 96dd0ab..df5fde6 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 @@ -8,7 +8,7 @@ import type { } from "../types.js"; import type { LanguageConfig } from "../languages/types.js"; import type { LanguageExtractor } from "./extractors/types.js"; -import { TypeScriptExtractor } from "./extractors/typescript-extractor.js"; +import { builtinExtractors } from "./extractors/index.js"; // web-tree-sitter uses CJS internally; we need createRequire for .wasm resolution const require = createRequire(import.meta.url); @@ -84,13 +84,15 @@ export class TreeSitterPlugin implements AnalyzerPlugin { this.languages = langs; - // Register extractors (default: TypeScript/JavaScript) + // Register extractors (default: all builtin extractors) if (extractors && extractors.length > 0) { for (const extractor of extractors) { this.registerExtractor(extractor); } } else { - this.registerExtractor(new TypeScriptExtractor()); + for (const extractor of builtinExtractors) { + this.registerExtractor(extractor); + } } } From f4b79e1d922ba5e3bb593cc96ae457be62d7132e Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 19:23:49 +0800 Subject: [PATCH 16/18] feat: add bundled tree-sitter extraction script for file-analyzer agent Replaces the LLM-generated throwaway regex scripts in Phase 1 of the file-analyzer with a deterministic script that uses PluginRegistry (TreeSitterPlugin + all non-code parsers) from @understand-anything/core. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../skills/understand/extract-structure.mjs | 296 ++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 understand-anything-plugin/skills/understand/extract-structure.mjs diff --git a/understand-anything-plugin/skills/understand/extract-structure.mjs b/understand-anything-plugin/skills/understand/extract-structure.mjs new file mode 100644 index 0000000..5208e0b --- /dev/null +++ b/understand-anything-plugin/skills/understand/extract-structure.mjs @@ -0,0 +1,296 @@ +#!/usr/bin/env node +/** + * extract-structure.mjs + * + * Deterministic structural extraction script for the file-analyzer agent. + * Uses PluginRegistry (TreeSitterPlugin + non-code parsers) from @understand-anything/core + * to replace the LLM-generated throwaway regex scripts in Phase 1. + * + * Usage: + * node extract-structure.mjs + * + * Input JSON: + * { projectRoot, batchFiles: [{path, language, sizeLines, fileCategory}], batchImportData } + * + * Output JSON: + * { scriptCompleted, filesAnalyzed, filesSkipped, results: [...] } + */ + +import { createRequire } from 'node:module'; +import { dirname, resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readFileSync, writeFileSync } from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +// skills/understand/ -> plugin root is two dirs up +const pluginRoot = resolve(__dirname, '../..'); +const require = createRequire(resolve(pluginRoot, 'package.json')); + +// --------------------------------------------------------------------------- +// Resolve @understand-anything/core +// --------------------------------------------------------------------------- +let core; +try { + core = await import(require.resolve('@understand-anything/core')); +} catch { + // Fallback: direct path for installed plugin cache layouts + core = await import(resolve(pluginRoot, 'packages/core/dist/index.js')); +} + +const { TreeSitterPlugin, PluginRegistry, builtinLanguageConfigs, registerAllParsers } = core; + +// --------------------------------------------------------------------------- +// Argument validation +// --------------------------------------------------------------------------- +const [,, inputPath, outputPath] = process.argv; +if (!inputPath || !outputPath) { + process.stderr.write('Usage: node extract-structure.mjs \n'); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +async function main() { + // Read input + const inputRaw = readFileSync(inputPath, 'utf-8'); + const input = JSON.parse(inputRaw); + const { projectRoot, batchFiles, batchImportData } = input; + + if (!projectRoot || !Array.isArray(batchFiles)) { + throw new Error('Invalid input: must contain projectRoot and batchFiles array'); + } + + // Create tree-sitter plugin with all configs that have WASM grammars + const tsConfigs = builtinLanguageConfigs.filter(c => c.treeSitter); + const tsPlugin = new TreeSitterPlugin(tsConfigs); + await tsPlugin.init(); + + // Create registry and register tree-sitter + all non-code parsers + const registry = new PluginRegistry(); + registry.register(tsPlugin); + registerAllParsers(registry); + + const results = []; + const filesSkipped = []; + + for (const file of batchFiles) { + const absolutePath = join(projectRoot, file.path); + + // Read file content + let content; + try { + content = readFileSync(absolutePath, 'utf-8'); + } catch (err) { + filesSkipped.push(file.path); + continue; + } + + // Line counts + const lines = content.split('\n'); + const totalLines = lines.length; + const nonEmptyLines = lines.filter(l => l.trim().length > 0).length; + + // Structural analysis via registry + let analysis = null; + try { + analysis = registry.analyzeFile(file.path, content); + } catch { + // If analysis throws, treat as degraded — still include basic metrics + } + + // Call graph extraction (code files only) + let callGraph = null; + if (file.fileCategory === 'code' || file.fileCategory === 'script') { + try { + const cg = registry.extractCallGraph(file.path, content); + if (cg && cg.length > 0) { + callGraph = cg.map(entry => ({ + caller: entry.caller, + callee: entry.callee, + lineNumber: entry.lineNumber, + })); + } + } catch { + // Call graph extraction failed — non-fatal + } + } + + // Build result object + const result = buildResult(file, totalLines, nonEmptyLines, analysis, callGraph, batchImportData); + results.push(result); + } + + // Write output + const output = { + scriptCompleted: true, + filesAnalyzed: results.length, + filesSkipped, + results, + }; + + writeFileSync(outputPath, JSON.stringify(output, null, 2), 'utf-8'); +} + +// --------------------------------------------------------------------------- +// Result builder: maps StructuralAnalysis to the expected output schema +// --------------------------------------------------------------------------- +function buildResult(file, totalLines, nonEmptyLines, analysis, callGraph, batchImportData) { + const base = { + path: file.path, + language: file.language, + fileCategory: file.fileCategory, + totalLines, + nonEmptyLines, + }; + + if (!analysis) { + // No parser matched — return basic metrics only + base.metrics = {}; + return base; + } + + const isCode = file.fileCategory === 'code' || file.fileCategory === 'script' || file.fileCategory === 'markup'; + + // Functions (code files) + if (analysis.functions && analysis.functions.length > 0) { + base.functions = analysis.functions.map(fn => ({ + name: fn.name, + startLine: fn.lineRange[0], + endLine: fn.lineRange[1], + params: fn.params || [], + })); + } + + // Classes (code files) + if (analysis.classes && analysis.classes.length > 0) { + base.classes = analysis.classes.map(cls => ({ + name: cls.name, + startLine: cls.lineRange[0], + endLine: cls.lineRange[1], + methods: cls.methods || [], + properties: cls.properties || [], + })); + } + + // Exports (code files) + if (analysis.exports && analysis.exports.length > 0) { + base.exports = analysis.exports.map(exp => ({ + name: exp.name, + line: exp.lineNumber, + isDefault: false, + })); + } + + // Non-code structural data: pass through directly + if (analysis.sections && analysis.sections.length > 0) { + base.sections = analysis.sections.map(s => ({ + heading: s.name, + level: s.level, + line: s.lineRange[0], + })); + } + + if (analysis.definitions && analysis.definitions.length > 0) { + base.definitions = analysis.definitions.map(d => ({ + name: d.name, + kind: d.kind, + fields: d.fields || [], + startLine: d.lineRange[0], + endLine: d.lineRange[1], + })); + } + + if (analysis.services && analysis.services.length > 0) { + base.services = analysis.services.map(s => ({ + name: s.name, + image: s.image, + ports: s.ports || [], + ...(s.lineRange ? { startLine: s.lineRange[0], endLine: s.lineRange[1] } : {}), + })); + } + + if (analysis.endpoints && analysis.endpoints.length > 0) { + base.endpoints = analysis.endpoints.map(e => ({ + method: e.method, + path: e.path, + startLine: e.lineRange[0], + endLine: e.lineRange[1], + })); + } + + if (analysis.steps && analysis.steps.length > 0) { + base.steps = analysis.steps.map(s => ({ + name: s.name, + startLine: s.lineRange[0], + endLine: s.lineRange[1], + })); + } + + if (analysis.resources && analysis.resources.length > 0) { + base.resources = analysis.resources.map(r => ({ + name: r.name, + kind: r.kind, + startLine: r.lineRange[0], + endLine: r.lineRange[1], + })); + } + + // Call graph + if (callGraph && callGraph.length > 0) { + base.callGraph = callGraph; + } + + // Metrics + const metrics = {}; + + // Import count from batchImportData (pre-resolved by project scanner) + const importPaths = batchImportData?.[file.path]; + if (importPaths) { + metrics.importCount = importPaths.length; + } else if (analysis.imports) { + metrics.importCount = analysis.imports.length; + } + + if (analysis.exports) { + metrics.exportCount = analysis.exports.length; + } + if (analysis.functions) { + metrics.functionCount = analysis.functions.length; + } + if (analysis.classes) { + metrics.classCount = analysis.classes.length; + } + if (analysis.sections) { + metrics.sectionCount = analysis.sections.length; + } + if (analysis.definitions) { + metrics.definitionCount = analysis.definitions.length; + } + if (analysis.services) { + metrics.serviceCount = analysis.services.length; + } + if (analysis.endpoints) { + metrics.endpointCount = analysis.endpoints.length; + } + if (analysis.steps) { + metrics.stepCount = analysis.steps.length; + } + if (analysis.resources) { + metrics.resourceCount = analysis.resources.length; + } + + base.metrics = metrics; + + return base; +} + +// --------------------------------------------------------------------------- +// Run +// --------------------------------------------------------------------------- +try { + await main(); +} catch (err) { + process.stderr.write(`extract-structure.mjs failed: ${err.message}\n${err.stack}\n`); + process.exit(1); +} From 20c1567143c83aec87e22913a581693303ddff5d Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Wed, 15 Apr 2026 19:26:39 +0800 Subject: [PATCH 17/18] feat: file-analyzer uses bundled tree-sitter script instead of LLM-generated regex Co-Authored-By: Claude Opus 4.6 (1M context) --- .../agents/file-analyzer.md | 241 ++---------------- .../skills/understand/SKILL.md | 1 + 2 files changed, 29 insertions(+), 213 deletions(-) diff --git a/understand-anything-plugin/agents/file-analyzer.md b/understand-anything-plugin/agents/file-analyzer.md index 5224765..3889526 100644 --- a/understand-anything-plugin/agents/file-analyzer.md +++ b/understand-anything-plugin/agents/file-analyzer.md @@ -19,148 +19,39 @@ For each file in the batch provided to you, extract structural data via a script --- -## Phase 1 -- Structural Extraction Script +## Phase 1 -- Structural Extraction (Bundled Script) -Write a script that reads each file in your batch and extracts deterministic structural information. Prefer Node.js for the script; fall back to Python if Node.js is unavailable. Avoid bash for complex extraction — it handles multiline patterns poorly. +Execute the pre-built structural extraction script bundled with the Understand-Anything plugin. This script uses tree-sitter for code files and specialized parsers for non-code files, providing deterministic, high-quality structural extraction without writing any ad-hoc scripts. -### Script Requirements +### Step 1 — Prepare the input JSON -1. **Accept** a JSON file path as the first argument. This JSON file contains: - ```json - { - "projectRoot": "/path/to/project", - "batchFiles": [ - {"path": "src/index.ts", "language": "typescript", "sizeLines": 150, "fileCategory": "code"}, - {"path": "README.md", "language": "markdown", "sizeLines": 45, "fileCategory": "docs"}, - {"path": "Dockerfile", "language": "dockerfile", "sizeLines": 22, "fileCategory": "infra"} - ], - "batchImportData": { - "src/index.ts": ["src/utils.ts", "src/config.ts"], - "README.md": [], - "Dockerfile": [] - } - } - ``` -2. **Write** results JSON to the path given as the second argument. -3. **Exit 0** on success. **Exit 1** on fatal error (print error to stderr). +Create the input file with the batch data. **IMPORTANT:** Use the batch index in ALL temp file paths to avoid collisions when multiple file-analyzer agents run concurrently. -### What the Script Must Extract (Per File) +```bash +cat > $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-.json << 'ENDJSON' +{ + "projectRoot": "", + "batchFiles": [], + "batchImportData": +} +ENDJSON +``` -The extraction approach depends on the file's `fileCategory`: +### Step 2 — Execute the bundled extraction script -#### For `code` files: +Run the bundled `extract-structure.mjs` script. The `` path is provided in your dispatch prompt. -**Functions and Methods:** -- Name, start line, end line, parameter names -- Detection approach: match `function `, `const = (`, `(` in class bodies, `def `, `func `, `fn `, `pub fn ` as appropriate for the language -- Include exported arrow functions and method definitions +```bash +node /extract-structure.mjs \ + $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-.json \ + $PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-.json +``` -**Classes, Interfaces, and Types:** -- Name, start line, end line -- Method names and property names within the class body -- Detection approach: match `class `, `interface `, `type =`, `struct `, `trait `, `impl ` as appropriate +If the script exits non-zero, read stderr and report the error. Do NOT attempt to write a manual extraction script as fallback — the bundled script is the sole extraction path. -**Imports:** -- Do NOT extract imports in the script. Import resolution has already been performed by the project scanner. -- The pre-resolved imports for each file are provided in `batchImportData` in the input JSON. -- Do not include an `imports` field in the script output — import edges will be created in Phase 2 using `batchImportData` directly. +### Step 3 — Read the extraction results -**Exports:** -- Exported names and their line numbers -- Whether it is a default export, named export, or re-export - -**Basic Metrics:** -- Total line count -- Non-empty line count (lines that are not blank or comment-only) -- Import count — use `batchImportData[file.path].length` from the input JSON (do not count from source) -- Export count (number of export statements) -- Function count, class count - -#### For `config` files (YAML, JSON, TOML, XML, .env, etc.): - -**Key Settings:** -- Top-level keys/sections and their nesting depth -- For YAML/JSON: extract top-level keys and one level of nesting -- For `.env` files: extract variable names (not values) -- For `tsconfig.json`, `package.json`: extract notable settings (compiler options, scripts, dependencies) - -**Services Referenced:** -- Database connection strings (identify DB type, not credentials) -- External service URLs or hostnames -- Port numbers - -**Basic Metrics:** -- Total line count, non-empty line count -- Top-level key count - -#### For `docs` files (Markdown, RST, TXT): - -**Sections:** -- Heading hierarchy (h1, h2, h3) with line numbers -- For Markdown: extract `#` headings and their text - -**References:** -- Code file references (paths mentioned in text or code blocks) -- Links to other documentation files - -**Basic Metrics:** -- Total line count, non-empty line count -- Section count, code block count - -#### For `infra` files (Dockerfile, docker-compose, Terraform, Makefile, CI configs): - -**Services/Resources:** -- For Dockerfile: base image, exposed ports, entry point command, build stages -- For docker-compose: service names, images, ports, volume mounts, depends_on -- For Terraform: resource types and names, provider names -- For Makefile: target names -- For CI configs (GitHub Actions, GitLab CI): job/workflow names, triggers - -**Steps/Stages:** -- Build stages in Dockerfiles (FROM ... AS ...) -- CI pipeline stages/jobs -- Makefile targets and their dependencies - -**Basic Metrics:** -- Total line count, non-empty line count -- Stage count / job count / target count - -#### For `data` files (SQL, GraphQL, Protobuf, Prisma): - -**Definitions:** -- For SQL: table names (CREATE TABLE), column names and types, foreign key relationships -- For GraphQL: type definitions, query/mutation names, field lists -- For Protobuf: message names, field names, service definitions -- For Prisma: model names, field names, relations - -**Relationships:** -- Foreign keys and references between tables/types -- Service dependencies - -**Basic Metrics:** -- Total line count, non-empty line count -- Table/type/message count, field count - -#### For `script` files (shell, PowerShell, batch): - -Treat similarly to `code` files: -- Extract function definitions (`function name()` or `name()` in bash) -- Extract significant commands and pipeline operations -- Basic metrics: total lines, non-empty lines, function count - -#### For `markup` files (HTML, CSS, SCSS): - -**Structural Elements:** -- For HTML: major semantic elements (`
`, `