mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
Merge pull request #89 from Lum1104/fix/graph-builder-use-language-registry
feat: multi-language tree-sitter extractor architecture (10 languages)
This commit is contained in:
@@ -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<string, LanguageExtractor>();
|
||||
|
||||
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-<batchIndex>.json << 'ENDJSON'
|
||||
{
|
||||
"projectRoot": "<project-root>",
|
||||
"batchFiles": [<this batch's files including fileCategory>],
|
||||
"batchImportData": <batchImportData JSON>
|
||||
}
|
||||
ENDJSON
|
||||
```
|
||||
|
||||
2. Execute the bundled script:
|
||||
```bash
|
||||
node <SKILL_DIR>/extract-structure.mjs \
|
||||
$PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json \
|
||||
$PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-<batchIndex>.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): `<SKILL_DIR>`
|
||||
```
|
||||
|
||||
This follows the established pattern — SKILL.md already passes `<SKILL_DIR>` 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__/<language>-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.
|
||||
Generated
+142
@@ -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
|
||||
|
||||
@@ -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-<batchIndex>.json << 'ENDJSON'
|
||||
{
|
||||
"projectRoot": "<project-root>",
|
||||
"batchFiles": [<this batch's files including fileCategory>],
|
||||
"batchImportData": <batchImportData JSON object — provided in your dispatch prompt>
|
||||
}
|
||||
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 `<SKILL_DIR>` path is provided in your dispatch prompt.
|
||||
|
||||
**Functions and Methods:**
|
||||
- Name, start line, end line, parameter names
|
||||
- Detection approach: match `function <name>`, `const <name> = (`, `<name>(` in class bodies, `def <name>`, `func <name>`, `fn <name>`, `pub fn <name>` as appropriate for the language
|
||||
- Include exported arrow functions and method definitions
|
||||
```bash
|
||||
node <SKILL_DIR>/extract-structure.mjs \
|
||||
$PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json \
|
||||
$PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-<batchIndex>.json
|
||||
```
|
||||
|
||||
**Classes, Interfaces, and Types:**
|
||||
- Name, start line, end line
|
||||
- Method names and property names within the class body
|
||||
- Detection approach: match `class <name>`, `interface <name>`, `type <name> =`, `struct <name>`, `trait <name>`, `impl <name>` 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 (`<main>`, `<nav>`, `<header>`, `<footer>`), component references, script/link tags
|
||||
- For CSS/SCSS: selector patterns, media queries, CSS custom properties (variables)
|
||||
|
||||
**Basic Metrics:**
|
||||
- Total line count, non-empty line count
|
||||
- Selector count (CSS) or element count (HTML)
|
||||
|
||||
### Script Output Format
|
||||
|
||||
The script must write this exact JSON structure to the output file:
|
||||
Read `$PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-<batchIndex>.json`. The output format is:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -181,8 +72,10 @@ The script must write this exact JSON structure to the output file:
|
||||
{"name": "App", "startLine": 50, "endLine": 140, "methods": ["init", "run"], "properties": ["config", "logger"]}
|
||||
],
|
||||
"exports": [
|
||||
{"name": "App", "line": 50, "isDefault": true},
|
||||
{"name": "createApp", "line": 145, "isDefault": false}
|
||||
{"name": "App", "line": 50, "isDefault": false}
|
||||
],
|
||||
"callGraph": [
|
||||
{"caller": "main", "callee": "initApp", "lineNumber": 15}
|
||||
],
|
||||
"metrics": {
|
||||
"importCount": 5,
|
||||
@@ -190,90 +83,12 @@ The script must write this exact JSON structure to the output file:
|
||||
"functionCount": 4,
|
||||
"classCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"language": "markdown",
|
||||
"fileCategory": "docs",
|
||||
"totalLines": 45,
|
||||
"nonEmptyLines": 38,
|
||||
"sections": [
|
||||
{"heading": "Project Name", "level": 1, "line": 1},
|
||||
{"heading": "Getting Started", "level": 2, "line": 10},
|
||||
{"heading": "API Reference", "level": 2, "line": 25}
|
||||
],
|
||||
"metrics": {
|
||||
"sectionCount": 3,
|
||||
"codeBlockCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "Dockerfile",
|
||||
"language": "dockerfile",
|
||||
"fileCategory": "infra",
|
||||
"totalLines": 22,
|
||||
"nonEmptyLines": 18,
|
||||
"services": [
|
||||
{"name": "build", "type": "stage", "baseImage": "node:20-alpine"},
|
||||
{"name": "production", "type": "stage", "baseImage": "node:20-alpine"}
|
||||
],
|
||||
"resources": [
|
||||
{"type": "port", "value": "3000"}
|
||||
],
|
||||
"metrics": {
|
||||
"stageCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "schema.sql",
|
||||
"language": "sql",
|
||||
"fileCategory": "data",
|
||||
"totalLines": 80,
|
||||
"nonEmptyLines": 65,
|
||||
"definitions": [
|
||||
{"name": "users", "type": "table", "columns": ["id", "email", "name", "created_at"]},
|
||||
{"name": "orders", "type": "table", "columns": ["id", "user_id", "total", "status"]}
|
||||
],
|
||||
"metrics": {
|
||||
"tableCount": 2,
|
||||
"columnCount": 8
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `scriptCompleted` (boolean) -- always `true` when the script finishes normally
|
||||
- `filesAnalyzed` (integer) -- count of files successfully processed
|
||||
- `filesSkipped` (string[]) -- files that could not be read (binary, permission error, etc.)
|
||||
- `results` (array) -- one entry per successfully analyzed file
|
||||
|
||||
### Preparing the Script Input
|
||||
|
||||
Before writing the script, create its input JSON file. **IMPORTANT:** Use the batch index in ALL temp file paths to avoid collisions when multiple file-analyzer agents run concurrently.
|
||||
|
||||
```bash
|
||||
cat > $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json << 'ENDJSON'
|
||||
{
|
||||
"projectRoot": "<project-root>",
|
||||
"batchFiles": [<this batch's files including fileCategory>],
|
||||
"batchImportData": <batchImportData JSON object — provided in your dispatch prompt>
|
||||
}
|
||||
ENDJSON
|
||||
```
|
||||
|
||||
### Executing the Script
|
||||
|
||||
After writing the script, execute it. **Use the batch index in every temp file path** — multiple file-analyzer agents run in parallel and must not overwrite each other's files:
|
||||
|
||||
```bash
|
||||
# For Node.js scripts:
|
||||
node $PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-<batchIndex>.js $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json $PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-<batchIndex>.json
|
||||
# For Python scripts:
|
||||
python3 $PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-<batchIndex>.py $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json $PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-<batchIndex>.json
|
||||
```
|
||||
|
||||
If the script exits with a non-zero code, read stderr, diagnose the issue, fix the script, and re-run. You have up to 2 retry attempts.
|
||||
**Supported file categories:** The bundled script handles all file categories — `code` (10 languages with tree-sitter: TypeScript, JavaScript, Python, Go, Rust, Java, Ruby, PHP, C/C++, C#), `config`, `docs`, `infra`, `data`, `script`, and `markup`. For languages without tree-sitter support (Swift, Kotlin), the script outputs basic metrics with empty structural data — use your judgment to supplement from source file reading if needed.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, GraphNode["type"]> = {
|
||||
output: "config",
|
||||
};
|
||||
|
||||
const EXTENSION_LANGUAGE: Record<string, string> = {
|
||||
// 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<string>();
|
||||
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}`;
|
||||
|
||||
@@ -11,6 +11,8 @@ export {
|
||||
type GraphIssue,
|
||||
} from "./schema.js";
|
||||
export { TreeSitterPlugin } from "./plugins/tree-sitter-plugin.js";
|
||||
export type { LanguageExtractor } from "./plugins/extractors/types.js";
|
||||
export { builtinExtractors } from "./plugins/extractors/index.js";
|
||||
export { GraphBuilder } from "./analyzer/graph-builder.js";
|
||||
export {
|
||||
buildFileAnalysisPrompt,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+696
@@ -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 <iostream>
|
||||
#include <vector>
|
||||
`);
|
||||
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 <iostream>
|
||||
#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 <iostream>
|
||||
#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 <stdio.h>
|
||||
#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 <stdlib.h>
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+665
@@ -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<string> 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<string>");
|
||||
|
||||
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<User> 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<User> GetUsers(int limit)
|
||||
{
|
||||
return FetchFromDb(limit);
|
||||
}
|
||||
|
||||
private void Log(string message)
|
||||
{
|
||||
Console.WriteLine(message);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IRepository
|
||||
{
|
||||
List<User> 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<User>");
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+599
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+568
@@ -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<String> 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<String>");
|
||||
|
||||
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<User> 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<User> getUsers(int limit) {
|
||||
return fetchFromDb(limit);
|
||||
}
|
||||
|
||||
private void log(String message) {
|
||||
System.out.println(message);
|
||||
}
|
||||
}
|
||||
|
||||
interface Repository {
|
||||
List<User> 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<User>");
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+604
@@ -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(`<?php
|
||||
function helper(string $x): string {
|
||||
return strtoupper($x);
|
||||
}
|
||||
|
||||
function greet(string $name, int $times): void {
|
||||
echo $name;
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.functions).toHaveLength(2);
|
||||
|
||||
expect(result.functions[0].name).toBe("helper");
|
||||
expect(result.functions[0].params).toEqual(["$x"]);
|
||||
expect(result.functions[0].returnType).toBe("string");
|
||||
|
||||
expect(result.functions[1].name).toBe("greet");
|
||||
expect(result.functions[1].params).toEqual(["$name", "$times"]);
|
||||
expect(result.functions[1].returnType).toBe("void");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts functions without return type", () => {
|
||||
const { tree, parser, root } = parse(`<?php
|
||||
function noReturn($x) {
|
||||
echo $x;
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.functions).toHaveLength(1);
|
||||
expect(result.functions[0].name).toBe("noReturn");
|
||||
expect(result.functions[0].returnType).toBeUndefined();
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts functions with no parameters", () => {
|
||||
const { tree, parser, root } = parse(`<?php
|
||||
function noop(): void {
|
||||
}
|
||||
`);
|
||||
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).toBe("void");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("reports correct line ranges", () => {
|
||||
const { tree, parser, root } = parse(`<?php
|
||||
function multiline(
|
||||
string $a,
|
||||
string $b
|
||||
): string {
|
||||
$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 classes with methods and properties", () => {
|
||||
const { tree, parser, root } = parse(`<?php
|
||||
class UserService {
|
||||
private string $name;
|
||||
protected int $maxRetries;
|
||||
|
||||
public function __construct(string $name) {
|
||||
$this->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(`<?php
|
||||
class Svc {
|
||||
public function run(string $x): string {
|
||||
return $x;
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.functions.some((f) => 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(`<?php
|
||||
class Factory {
|
||||
public static function create(): self {
|
||||
return new self();
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes).toHaveLength(1);
|
||||
expect(result.classes[0].methods).toContain("create");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts nullable and optional type properties", () => {
|
||||
const { tree, parser, root } = parse(`<?php
|
||||
class Config {
|
||||
public ?string $nullable;
|
||||
public static int $counter = 0;
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes).toHaveLength(1);
|
||||
expect(result.classes[0].properties).toContain("nullable");
|
||||
expect(result.classes[0].properties).toContain("counter");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("reports correct class line ranges", () => {
|
||||
const { tree, parser, root } = parse(`<?php
|
||||
class MyClass {
|
||||
public function a(): void {}
|
||||
public function b(): void {}
|
||||
}
|
||||
`);
|
||||
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(5);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Interfaces ----
|
||||
|
||||
describe("extractStructure - interfaces", () => {
|
||||
it("extracts interfaces with method signatures", () => {
|
||||
const { tree, parser, root } = parse(`<?php
|
||||
interface Loggable {
|
||||
public function log(string $msg): void;
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes).toHaveLength(1);
|
||||
expect(result.classes[0].name).toBe("Loggable");
|
||||
expect(result.classes[0].methods).toContain("log");
|
||||
expect(result.classes[0].properties).toEqual([]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("treats interfaces as exports", () => {
|
||||
const { tree, parser, root } = parse(`<?php
|
||||
interface Repository {
|
||||
public function find(int $id): mixed;
|
||||
public function save(object $entity): void;
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
const exportNames = result.exports.map((e) => 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(`<?php
|
||||
use App\\Models\\User;
|
||||
use App\\Contracts\\Repository;
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
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"]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts grouped use statements", () => {
|
||||
const { tree, parser, root } = parse(`<?php
|
||||
use App\\Models\\{User, Post};
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.imports).toHaveLength(1);
|
||||
expect(result.imports[0].specifiers).toEqual(["User", "Post"]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts aliased use statements", () => {
|
||||
const { tree, parser, root } = parse(`<?php
|
||||
use App\\Contracts\\Repository as Repo;
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.imports).toHaveLength(1);
|
||||
expect(result.imports[0].source).toBe("App\\Contracts\\Repository");
|
||||
expect(result.imports[0].specifiers).toEqual(["Repository"]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("reports correct import line numbers", () => {
|
||||
const { tree, parser, root } = parse(`<?php
|
||||
use App\\Models\\User;
|
||||
use App\\Models\\Post;
|
||||
`);
|
||||
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(`<?php
|
||||
function publicFunc(): void {}
|
||||
function anotherFunc(string $x): string { return $x; }
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
const exportNames = result.exports.map((e) => 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(`<?php
|
||||
class MyService {}
|
||||
class MyModel {}
|
||||
`);
|
||||
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("does not treat use statements as exports", () => {
|
||||
const { tree, parser, root } = parse(`<?php
|
||||
use App\\Models\\User;
|
||||
|
||||
function myFunc(): void {}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.exports).toHaveLength(1);
|
||||
expect(result.exports[0].name).toBe("myFunc");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Call Graph ----
|
||||
|
||||
describe("extractCallGraph", () => {
|
||||
it("extracts standalone function calls", () => {
|
||||
const { tree, parser, root } = parse(`<?php
|
||||
function process(string $data): string {
|
||||
$result = transform($data);
|
||||
return format_output($result);
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractCallGraph(root);
|
||||
|
||||
const callees = result.map((e) => 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(`<?php
|
||||
class Svc {
|
||||
public function getUser(int $id): User {
|
||||
return $this->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(`<?php
|
||||
class Foo {
|
||||
public function doWork(): void {
|
||||
$result = Bar::staticMethod();
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractCallGraph(root);
|
||||
|
||||
expect(result.some((e) => 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(`<?php
|
||||
class Service {
|
||||
public function start(): void {
|
||||
$this->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(`<?php
|
||||
echo "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("reports correct line numbers for calls", () => {
|
||||
const { tree, parser, root } = parse(`<?php
|
||||
function main(): void {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Comprehensive ----
|
||||
|
||||
describe("comprehensive PHP file", () => {
|
||||
it("handles the full test fixture", () => {
|
||||
const { tree, parser, root } = parse(`<?php
|
||||
namespace App\\Services;
|
||||
|
||||
use App\\Models\\User;
|
||||
use App\\Contracts\\Repository;
|
||||
|
||||
class UserService {
|
||||
private string $name;
|
||||
protected int $maxRetries;
|
||||
|
||||
public function __construct(string $name) {
|
||||
$this->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(`<?php
|
||||
function findUser(int $id): ?User {
|
||||
return null;
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.functions).toHaveLength(1);
|
||||
expect(result.functions[0].returnType).toBe("?User");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
});
|
||||
});
|
||||
+659
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+688
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+755
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, string[]>();
|
||||
|
||||
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<string, string[]>,
|
||||
): 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: `<iostream>`
|
||||
* - `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> -> 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<string, string[]>,
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
@@ -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<T>),
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string[]>();
|
||||
|
||||
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<string, string[]>,
|
||||
): 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
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 PHP `formal_parameters` node.
|
||||
*
|
||||
* Each child is a `simple_parameter` containing an optional type hint
|
||||
* and a `variable_name` node (which itself has `$` + `name` children).
|
||||
* We extract the variable name prefixed with `$`.
|
||||
*/
|
||||
function extractParams(paramsNode: TreeSitterNode | null): string[] {
|
||||
if (!paramsNode) return [];
|
||||
const params: string[] = [];
|
||||
|
||||
const simpleParams = findChildren(paramsNode, "simple_parameter");
|
||||
for (const param of simpleParams) {
|
||||
const varName = findChild(param, "variable_name");
|
||||
if (varName) {
|
||||
params.push(varName.text);
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a return type string from the siblings following the `formal_parameters`
|
||||
* in a function_definition or method_declaration node.
|
||||
*
|
||||
* In tree-sitter-php, the return type appears as a sibling after `:` and can be:
|
||||
* - `primitive_type` (string, int, void, bool, etc.)
|
||||
* - `named_type` (User, Repository, etc.)
|
||||
* - `optional_type` (?string, ?User)
|
||||
* - `union_type` (string|int)
|
||||
*/
|
||||
function extractReturnType(node: TreeSitterNode): string | undefined {
|
||||
// Walk children looking for the colon separator that precedes the return type.
|
||||
// The return type node follows the `:` and precedes the `compound_statement` (body).
|
||||
let foundColon = false;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
|
||||
if (child.type === ":" && child.text === ":") {
|
||||
foundColon = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (foundColon) {
|
||||
// The next non-punctuation node after `:` is the return type
|
||||
if (
|
||||
child.type === "primitive_type" ||
|
||||
child.type === "named_type" ||
|
||||
child.type === "optional_type" ||
|
||||
child.type === "union_type"
|
||||
) {
|
||||
return child.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a fully-qualified name from a `namespace_use_clause`.
|
||||
*
|
||||
* For a simple clause like `use App\Models\User;`, the clause contains
|
||||
* a `qualified_name` with `namespace_name` segments and a trailing `name`.
|
||||
* For a grouped clause like `use App\Models\{User, Post};`, the clause
|
||||
* is a direct child of `namespace_use_group` and may be a bare `name`.
|
||||
*/
|
||||
function extractUseName(clause: TreeSitterNode, prefix: string): string {
|
||||
const qualifiedName = findChild(clause, "qualified_name");
|
||||
if (qualifiedName) {
|
||||
return qualifiedName.text;
|
||||
}
|
||||
// Inside a grouped use, the clause may just be a `name` node
|
||||
const nameNode = findChild(clause, "name");
|
||||
if (nameNode && prefix) {
|
||||
return prefix + "\\" + nameNode.text;
|
||||
}
|
||||
if (nameNode) {
|
||||
return nameNode.text;
|
||||
}
|
||||
return clause.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the last segment (class/interface name) from a fully-qualified name.
|
||||
* e.g., "App\Models\User" -> "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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string[]>();
|
||||
|
||||
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<string, string[]>,
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
+483
@@ -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<string>();
|
||||
|
||||
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<string>,
|
||||
): 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<string>,
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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 { builtinExtractors } from "./extractors/index.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<string, string>();
|
||||
private _initialized = false;
|
||||
|
||||
// Language-specific extractors (keyed by language id)
|
||||
private extractors = new Map<string, LanguageExtractor>();
|
||||
|
||||
/**
|
||||
* 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,29 @@ export class TreeSitterPlugin implements AnalyzerPlugin {
|
||||
}
|
||||
|
||||
this.languages = langs;
|
||||
|
||||
// Register extractors (default: all builtin extractors)
|
||||
if (extractors && extractors.length > 0) {
|
||||
for (const extractor of extractors) {
|
||||
this.registerExtractor(extractor);
|
||||
}
|
||||
} else {
|
||||
for (const extractor of builtinExtractors) {
|
||||
this.registerExtractor(extractor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +233,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<string>();
|
||||
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 +287,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<string>,
|
||||
): 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<string>,
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +196,7 @@ Fill in batch-specific parameters below and dispatch:
|
||||
> Project: `<projectName>`
|
||||
> Languages: `<languages>`
|
||||
> Batch index: `<batchIndex>`
|
||||
> Skill directory (for bundled scripts): `<SKILL_DIR>`
|
||||
> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/batch-<batchIndex>.json`
|
||||
>
|
||||
> Pre-resolved import data for this batch (use this for all import edge creation — do NOT re-resolve imports from source):
|
||||
|
||||
@@ -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> <output.json>
|
||||
*
|
||||
* 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 <input.json> <output.json>\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);
|
||||
}
|
||||
Reference in New Issue
Block a user