Files
codegraph/src/extraction/tree-sitter.ts
T
Colby McHenry 36af284e10 Remove redundant code and deduplicate indexFile
- Remove extractFunctionVariable() and its dispatch (already handled by extractVariable)
- Remove dead getGrammar() export (zero callers)
- Deduplicate indexFile by delegating to indexFileWithContent
- Remove redundant arrow function variable extraction tests (covered by existing suite)
2026-02-10 15:55:43 -06:00

2551 lines
85 KiB
TypeScript

/**
* Tree-sitter Parser Wrapper
*
* Handles parsing source code and extracting structural information.
*/
import { SyntaxNode, Tree } from 'tree-sitter';
import * as crypto from 'crypto';
import * as path from 'path';
import {
Language,
Node,
Edge,
NodeKind,
ExtractionResult,
ExtractionError,
UnresolvedReference,
} from '../types';
import { getParser, detectLanguage, isLanguageSupported } from './grammars';
import { captureException } from '../sentry';
/**
* Generate a unique node ID
*
* Uses a 32-character (128-bit) hash to avoid collisions when indexing
* large codebases with many files containing similar symbols.
*/
export function generateNodeId(
filePath: string,
kind: NodeKind,
name: string,
line: number
): string {
const hash = crypto
.createHash('sha256')
.update(`${filePath}:${kind}:${name}:${line}`)
.digest('hex')
.substring(0, 32);
return `${kind}:${hash}`;
}
/**
* Extract text from a syntax node
*/
function getNodeText(node: SyntaxNode, source: string): string {
return source.substring(node.startIndex, node.endIndex);
}
/**
* Find a child node by field name
*/
function getChildByField(node: SyntaxNode, fieldName: string): SyntaxNode | null {
return node.childForFieldName(fieldName);
}
/**
* Get the docstring/comment preceding a node
*/
function getPrecedingDocstring(node: SyntaxNode, source: string): string | undefined {
let sibling = node.previousNamedSibling;
const comments: string[] = [];
while (sibling) {
if (
sibling.type === 'comment' ||
sibling.type === 'line_comment' ||
sibling.type === 'block_comment' ||
sibling.type === 'documentation_comment'
) {
comments.unshift(getNodeText(sibling, source));
sibling = sibling.previousNamedSibling;
} else {
break;
}
}
if (comments.length === 0) return undefined;
// Clean up comment markers
return comments
.map((c) =>
c
.replace(/^\/\*\*?|\*\/$/g, '')
.replace(/^\/\/\s?/gm, '')
.replace(/^\s*\*\s?/gm, '')
.trim()
)
.join('\n')
.trim();
}
/**
* Language-specific extraction configuration
*/
interface LanguageExtractor {
/** Node types that represent functions */
functionTypes: string[];
/** Node types that represent classes */
classTypes: string[];
/** Node types that represent methods */
methodTypes: string[];
/** Node types that represent interfaces/protocols/traits */
interfaceTypes: string[];
/** Node types that represent structs */
structTypes: string[];
/** Node types that represent enums */
enumTypes: string[];
/** Node types that represent type aliases (e.g. `type X = ...`) */
typeAliasTypes: string[];
/** Node types that represent imports */
importTypes: string[];
/** Node types that represent function calls */
callTypes: string[];
/** Node types that represent variable declarations (const, let, var, etc.) */
variableTypes: string[];
/** Field name for identifier/name */
nameField: string;
/** Field name for body */
bodyField: string;
/** Field name for parameters */
paramsField: string;
/** Field name for return type */
returnField?: string;
/** Extract signature from node */
getSignature?: (node: SyntaxNode, source: string) => string | undefined;
/** Extract visibility from node */
getVisibility?: (node: SyntaxNode) => 'public' | 'private' | 'protected' | 'internal' | undefined;
/** Check if node is exported */
isExported?: (node: SyntaxNode, source: string) => boolean;
/** Check if node is async */
isAsync?: (node: SyntaxNode) => boolean;
/** Check if node is static */
isStatic?: (node: SyntaxNode) => boolean;
/** Check if variable declaration is a constant (const vs let/var) */
isConst?: (node: SyntaxNode) => boolean;
}
/**
* Language-specific extractors
*/
const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
typescript: {
functionTypes: ['function_declaration', 'arrow_function', 'function_expression'],
classTypes: ['class_declaration'],
methodTypes: ['method_definition', 'public_field_definition'],
interfaceTypes: ['interface_declaration'],
structTypes: [],
enumTypes: ['enum_declaration'],
typeAliasTypes: ['type_alias_declaration'],
importTypes: ['import_statement'],
callTypes: ['call_expression'],
variableTypes: ['lexical_declaration', 'variable_declaration'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
returnField: 'return_type',
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
const returnType = getChildByField(node, 'return_type');
if (!params) return undefined;
let sig = getNodeText(params, source);
if (returnType) {
sig += ': ' + getNodeText(returnType, source).replace(/^:\s*/, '');
}
return sig;
},
getVisibility: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'accessibility_modifier') {
const text = child.text;
if (text === 'public') return 'public';
if (text === 'private') return 'private';
if (text === 'protected') return 'protected';
}
}
return undefined;
},
isExported: (node, _source) => {
// Walk the parent chain to find an export_statement ancestor.
// This correctly handles deeply nested nodes like arrow functions
// inside variable declarations: `export const X = () => { ... }`
// where the arrow_function is 3 levels deep under export_statement.
let current = node.parent;
while (current) {
if (current.type === 'export_statement') return true;
current = current.parent;
}
return false;
},
isAsync: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'async') return true;
}
return false;
},
isStatic: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'static') return true;
}
return false;
},
isConst: (node) => {
// For lexical_declaration, check if it's 'const' or 'let'
// For variable_declaration, it's always 'var'
if (node.type === 'lexical_declaration') {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'const') return true;
}
}
return false;
},
},
javascript: {
functionTypes: ['function_declaration', 'arrow_function', 'function_expression'],
classTypes: ['class_declaration'],
methodTypes: ['method_definition', 'field_definition'],
interfaceTypes: [],
structTypes: [],
enumTypes: [],
typeAliasTypes: [],
importTypes: ['import_statement'],
callTypes: ['call_expression'],
variableTypes: ['lexical_declaration', 'variable_declaration'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
return params ? getNodeText(params, source) : undefined;
},
isExported: (node, _source) => {
let current = node.parent;
while (current) {
if (current.type === 'export_statement') return true;
current = current.parent;
}
return false;
},
isAsync: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'async') return true;
}
return false;
},
isConst: (node) => {
if (node.type === 'lexical_declaration') {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'const') return true;
}
}
return false;
},
},
python: {
functionTypes: ['function_definition'],
classTypes: ['class_definition'],
methodTypes: ['function_definition'], // Methods are functions inside classes
interfaceTypes: [],
structTypes: [],
enumTypes: [],
typeAliasTypes: [],
importTypes: ['import_statement', 'import_from_statement'],
callTypes: ['call'],
variableTypes: ['assignment'], // Python uses assignment for variable declarations
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
returnField: 'return_type',
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
const returnType = getChildByField(node, 'return_type');
if (!params) return undefined;
let sig = getNodeText(params, source);
if (returnType) {
sig += ' -> ' + getNodeText(returnType, source);
}
return sig;
},
isAsync: (node) => {
const prev = node.previousSibling;
return prev?.type === 'async';
},
isStatic: (node) => {
// Check for @staticmethod decorator
const prev = node.previousNamedSibling;
if (prev?.type === 'decorator') {
const text = prev.text;
return text.includes('staticmethod');
}
return false;
},
},
go: {
functionTypes: ['function_declaration'],
classTypes: [], // Go doesn't have classes
methodTypes: ['method_declaration'],
interfaceTypes: ['interface_type'],
structTypes: ['struct_type'],
enumTypes: [],
typeAliasTypes: ['type_spec'], // Go type declarations
importTypes: ['import_declaration'],
callTypes: ['call_expression'],
variableTypes: ['var_declaration', 'short_var_declaration', 'const_declaration'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
returnField: 'result',
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
const result = getChildByField(node, 'result');
if (!params) return undefined;
let sig = getNodeText(params, source);
if (result) {
sig += ' ' + getNodeText(result, source);
}
return sig;
},
},
rust: {
functionTypes: ['function_item'],
classTypes: [], // Rust has impl blocks
methodTypes: ['function_item'], // Methods are functions in impl blocks
interfaceTypes: ['trait_item'],
structTypes: ['struct_item'],
enumTypes: ['enum_item'],
typeAliasTypes: ['type_item'], // Rust type aliases
importTypes: ['use_declaration'],
callTypes: ['call_expression'],
variableTypes: ['let_declaration', 'const_item', 'static_item'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
returnField: 'return_type',
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
const returnType = getChildByField(node, 'return_type');
if (!params) return undefined;
let sig = getNodeText(params, source);
if (returnType) {
sig += ' -> ' + getNodeText(returnType, source);
}
return sig;
},
isAsync: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'async') return true;
}
return false;
},
getVisibility: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'visibility_modifier') {
return child.text.includes('pub') ? 'public' : 'private';
}
}
return 'private'; // Rust defaults to private
},
},
java: {
functionTypes: [],
classTypes: ['class_declaration'],
methodTypes: ['method_declaration', 'constructor_declaration'],
interfaceTypes: ['interface_declaration'],
structTypes: [],
enumTypes: ['enum_declaration'],
typeAliasTypes: [],
importTypes: ['import_declaration'],
callTypes: ['method_invocation'],
variableTypes: ['local_variable_declaration', 'field_declaration'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
returnField: 'type',
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
const returnType = getChildByField(node, 'type');
if (!params) return undefined;
const paramsText = getNodeText(params, source);
return returnType ? getNodeText(returnType, source) + ' ' + paramsText : paramsText;
},
getVisibility: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers') {
const text = child.text;
if (text.includes('public')) return 'public';
if (text.includes('private')) return 'private';
if (text.includes('protected')) return 'protected';
}
}
return undefined;
},
isStatic: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers' && child.text.includes('static')) {
return true;
}
}
return false;
},
},
c: {
functionTypes: ['function_definition'],
classTypes: [],
methodTypes: [],
interfaceTypes: [],
structTypes: ['struct_specifier'],
enumTypes: ['enum_specifier'],
typeAliasTypes: ['type_definition'], // typedef
importTypes: ['preproc_include'],
callTypes: ['call_expression'],
variableTypes: ['declaration'],
nameField: 'declarator',
bodyField: 'body',
paramsField: 'parameters',
},
cpp: {
functionTypes: ['function_definition'],
classTypes: ['class_specifier'],
methodTypes: ['function_definition'],
interfaceTypes: [],
structTypes: ['struct_specifier'],
enumTypes: ['enum_specifier'],
typeAliasTypes: ['type_definition', 'alias_declaration'], // typedef and using
importTypes: ['preproc_include'],
callTypes: ['call_expression'],
variableTypes: ['declaration'],
nameField: 'declarator',
bodyField: 'body',
paramsField: 'parameters',
getVisibility: (node) => {
// Check for access specifier in parent
const parent = node.parent;
if (parent) {
for (let i = 0; i < parent.childCount; i++) {
const child = parent.child(i);
if (child?.type === 'access_specifier') {
const text = child.text;
if (text.includes('public')) return 'public';
if (text.includes('private')) return 'private';
if (text.includes('protected')) return 'protected';
}
}
}
return undefined;
},
},
csharp: {
functionTypes: [],
classTypes: ['class_declaration'],
methodTypes: ['method_declaration', 'constructor_declaration'],
interfaceTypes: ['interface_declaration'],
structTypes: ['struct_declaration'],
enumTypes: ['enum_declaration'],
typeAliasTypes: [],
importTypes: ['using_directive'],
callTypes: ['invocation_expression'],
variableTypes: ['local_declaration_statement', 'field_declaration'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameter_list',
getVisibility: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifier') {
const text = child.text;
if (text === 'public') return 'public';
if (text === 'private') return 'private';
if (text === 'protected') return 'protected';
if (text === 'internal') return 'internal';
}
}
return 'private'; // C# defaults to private
},
isStatic: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifier' && child.text === 'static') {
return true;
}
}
return false;
},
isAsync: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifier' && child.text === 'async') {
return true;
}
}
return false;
},
},
php: {
functionTypes: ['function_definition'],
classTypes: ['class_declaration'],
methodTypes: ['method_declaration'],
interfaceTypes: ['interface_declaration'],
structTypes: [],
enumTypes: ['enum_declaration'],
typeAliasTypes: [],
importTypes: ['namespace_use_declaration'],
callTypes: ['function_call_expression', 'member_call_expression', 'scoped_call_expression'],
variableTypes: ['property_declaration', 'const_declaration'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
returnField: 'return_type',
getVisibility: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'visibility_modifier') {
const text = child.text;
if (text === 'public') return 'public';
if (text === 'private') return 'private';
if (text === 'protected') return 'protected';
}
}
return 'public'; // PHP defaults to public
},
isStatic: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'static_modifier') return true;
}
return false;
},
},
ruby: {
functionTypes: ['method'],
classTypes: ['class'],
methodTypes: ['method', 'singleton_method'],
interfaceTypes: [], // Ruby uses modules
structTypes: [],
enumTypes: [],
typeAliasTypes: [],
importTypes: ['call'], // require/require_relative
callTypes: ['call', 'method_call'],
variableTypes: ['assignment'], // Ruby uses assignment like Python
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
getVisibility: (node) => {
// Ruby visibility is based on preceding visibility modifiers
let sibling = node.previousNamedSibling;
while (sibling) {
if (sibling.type === 'call') {
const methodName = getChildByField(sibling, 'method');
if (methodName) {
const text = methodName.text;
if (text === 'private') return 'private';
if (text === 'protected') return 'protected';
if (text === 'public') return 'public';
}
}
sibling = sibling.previousNamedSibling;
}
return 'public';
},
},
swift: {
functionTypes: ['function_declaration'],
classTypes: ['class_declaration'],
methodTypes: ['function_declaration'], // Methods are functions inside classes
interfaceTypes: ['protocol_declaration'],
structTypes: ['struct_declaration'],
enumTypes: ['enum_declaration'],
typeAliasTypes: ['typealias_declaration'],
importTypes: ['import_declaration'],
callTypes: ['call_expression'],
variableTypes: ['property_declaration', 'constant_declaration'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameter',
returnField: 'return_type',
getSignature: (node, source) => {
// Swift function signature: func name(params) -> ReturnType
const params = getChildByField(node, 'parameter');
const returnType = getChildByField(node, 'return_type');
if (!params) return undefined;
let sig = getNodeText(params, source);
if (returnType) {
sig += ' -> ' + getNodeText(returnType, source);
}
return sig;
},
getVisibility: (node) => {
// Check for visibility modifiers in Swift
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers') {
const text = child.text;
if (text.includes('public')) return 'public';
if (text.includes('private')) return 'private';
if (text.includes('internal')) return 'internal';
if (text.includes('fileprivate')) return 'private';
}
}
return 'internal'; // Swift defaults to internal
},
isStatic: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers') {
if (child.text.includes('static') || child.text.includes('class')) {
return true;
}
}
}
return false;
},
isAsync: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers' && child.text.includes('async')) {
return true;
}
}
return false;
},
},
kotlin: {
functionTypes: ['function_declaration'],
classTypes: ['class_declaration'],
methodTypes: ['function_declaration'], // Methods are functions inside classes
interfaceTypes: ['class_declaration'], // Interfaces use class_declaration with 'interface' modifier
structTypes: [], // Kotlin uses data classes
enumTypes: ['class_declaration'], // Enums use class_declaration with 'enum' modifier
typeAliasTypes: ['type_alias'],
importTypes: ['import_header'],
callTypes: ['call_expression'],
variableTypes: ['property_declaration'],
nameField: 'simple_identifier',
bodyField: 'function_body',
paramsField: 'function_value_parameters',
returnField: 'type',
getSignature: (node, source) => {
// Kotlin function signature: fun name(params): ReturnType
const params = getChildByField(node, 'function_value_parameters');
const returnType = getChildByField(node, 'type');
if (!params) return undefined;
let sig = getNodeText(params, source);
if (returnType) {
sig += ': ' + getNodeText(returnType, source);
}
return sig;
},
getVisibility: (node) => {
// Check for visibility modifiers in Kotlin
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers') {
const text = child.text;
if (text.includes('public')) return 'public';
if (text.includes('private')) return 'private';
if (text.includes('protected')) return 'protected';
if (text.includes('internal')) return 'internal';
}
}
return 'public'; // Kotlin defaults to public
},
isStatic: (_node) => {
// Kotlin doesn't have static, uses companion objects
// Check if inside companion object would require more context
return false;
},
isAsync: (node) => {
// Kotlin uses suspend keyword for coroutines
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers' && child.text.includes('suspend')) {
return true;
}
}
return false;
},
},
dart: {
functionTypes: ['function_signature'],
classTypes: ['class_definition'],
methodTypes: ['method_signature'],
interfaceTypes: [],
structTypes: [],
enumTypes: ['enum_declaration'],
typeAliasTypes: ['type_alias'],
importTypes: ['import_or_export'],
callTypes: [], // Dart calls use identifier+selector, handled via function body traversal
variableTypes: [],
nameField: 'name',
bodyField: 'body', // class_definition uses 'body' field
paramsField: 'formal_parameter_list',
returnField: 'type',
getSignature: (node, source) => {
// For function_signature: extract params + return type
// For method_signature: delegate to inner function_signature
let sig = node;
if (node.type === 'method_signature') {
const inner = node.namedChildren.find((c: SyntaxNode) =>
c.type === 'function_signature' || c.type === 'getter_signature' || c.type === 'setter_signature'
);
if (inner) sig = inner;
}
const params = sig.namedChildren.find((c: SyntaxNode) => c.type === 'formal_parameter_list');
const retType = sig.namedChildren.find((c: SyntaxNode) =>
c.type === 'type_identifier' || c.type === 'void_type'
);
if (!params && !retType) return undefined;
let result = '';
if (retType) result += getNodeText(retType, source) + ' ';
if (params) result += getNodeText(params, source);
return result.trim() || undefined;
},
getVisibility: (node) => {
// Dart convention: _ prefix means private, otherwise public
let nameNode: SyntaxNode | null = null;
if (node.type === 'method_signature') {
const inner = node.namedChildren.find((c: SyntaxNode) =>
c.type === 'function_signature' || c.type === 'getter_signature' || c.type === 'setter_signature'
);
if (inner) nameNode = inner.namedChildren.find((c: SyntaxNode) => c.type === 'identifier') || null;
} else {
nameNode = node.childForFieldName('name');
}
if (nameNode && nameNode.text.startsWith('_')) return 'private';
return 'public';
},
isAsync: (node) => {
// In Dart, 'async' is on the function_body (next sibling), not the signature
const nextSibling = node.nextNamedSibling;
if (nextSibling?.type === 'function_body') {
for (let i = 0; i < nextSibling.childCount; i++) {
const child = nextSibling.child(i);
if (child?.type === 'async') return true;
}
}
return false;
},
isStatic: (node) => {
// For method_signature, check for 'static' child
if (node.type === 'method_signature') {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'static') return true;
}
}
return false;
},
},
};
// TSX and JSX use the same extractors as their base languages
EXTRACTORS.tsx = EXTRACTORS.typescript;
EXTRACTORS.jsx = EXTRACTORS.javascript;
/**
* Extract the name from a node based on language
*/
function extractName(node: SyntaxNode, source: string, extractor: LanguageExtractor): string {
// Try field name first
const nameNode = getChildByField(node, extractor.nameField);
if (nameNode) {
// Handle complex declarators (C/C++)
if (nameNode.type === 'function_declarator' || nameNode.type === 'declarator') {
const innerName = getChildByField(nameNode, 'declarator') || nameNode.namedChild(0);
return innerName ? getNodeText(innerName, source) : getNodeText(nameNode, source);
}
return getNodeText(nameNode, source);
}
// For Dart method_signature, look inside inner signature types
if (node.type === 'method_signature') {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child && (
child.type === 'function_signature' ||
child.type === 'getter_signature' ||
child.type === 'setter_signature' ||
child.type === 'constructor_signature' ||
child.type === 'factory_constructor_signature'
)) {
// Find identifier inside the inner signature
for (let j = 0; j < child.namedChildCount; j++) {
const inner = child.namedChild(j);
if (inner?.type === 'identifier') {
return getNodeText(inner, source);
}
}
}
}
}
// Fall back to first identifier child
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (
child &&
(child.type === 'identifier' ||
child.type === 'type_identifier' ||
child.type === 'simple_identifier' ||
child.type === 'constant')
) {
return getNodeText(child, source);
}
}
return '<anonymous>';
}
/**
* TreeSitterExtractor - Main extraction class
*/
export class TreeSitterExtractor {
private filePath: string;
private language: Language;
private source: string;
private tree: Tree | null = null;
private nodes: Node[] = [];
private edges: Edge[] = [];
private unresolvedReferences: UnresolvedReference[] = [];
private errors: ExtractionError[] = [];
private extractor: LanguageExtractor | null = null;
private nodeStack: string[] = []; // Stack of parent node IDs
constructor(filePath: string, source: string, language?: Language) {
this.filePath = filePath;
this.source = source;
this.language = language || detectLanguage(filePath);
this.extractor = EXTRACTORS[this.language] || null;
}
/**
* Parse and extract from the source code
*/
extract(): ExtractionResult {
const startTime = Date.now();
if (!isLanguageSupported(this.language)) {
return {
nodes: [],
edges: [],
unresolvedReferences: [],
errors: [
{
message: `Unsupported language: ${this.language}`,
severity: 'error',
},
],
durationMs: Date.now() - startTime,
};
}
const parser = getParser(this.language);
if (!parser) {
return {
nodes: [],
edges: [],
unresolvedReferences: [],
errors: [
{
message: `Failed to get parser for language: ${this.language}`,
severity: 'error',
},
],
durationMs: Date.now() - startTime,
};
}
try {
this.tree = parser.parse(this.source);
// Create file node representing the source file
const fileNode: Node = {
id: `file:${this.filePath}`,
kind: 'file',
name: path.basename(this.filePath),
qualifiedName: this.filePath,
filePath: this.filePath,
language: this.language,
startLine: 1,
endLine: this.source.split('\n').length,
startColumn: 0,
endColumn: 0,
isExported: false,
updatedAt: Date.now(),
};
this.nodes.push(fileNode);
// Push file node onto stack so top-level declarations get contains edges
this.nodeStack.push(fileNode.id);
this.visitNode(this.tree.rootNode);
this.nodeStack.pop();
} catch (error) {
captureException(error, { operation: 'tree-sitter-parse', filePath: this.filePath, language: this.language });
this.errors.push({
message: `Parse error: ${error instanceof Error ? error.message : String(error)}`,
severity: 'error',
});
}
return {
nodes: this.nodes,
edges: this.edges,
unresolvedReferences: this.unresolvedReferences,
errors: this.errors,
durationMs: Date.now() - startTime,
};
}
/**
* Visit a node and extract information
*/
private visitNode(node: SyntaxNode): void {
if (!this.extractor) return;
const nodeType = node.type;
let skipChildren = false;
// Check for function declarations
// For Python/Ruby, function_definition inside a class should be treated as method
if (this.extractor.functionTypes.includes(nodeType)) {
if (this.isInsideClassLikeNode() && this.extractor.methodTypes.includes(nodeType)) {
// Inside a class - treat as method
this.extractMethod(node);
skipChildren = true; // extractMethod visits children via visitFunctionBody
} else {
this.extractFunction(node);
skipChildren = true; // extractFunction visits children via visitFunctionBody
}
}
// Check for class declarations
else if (this.extractor.classTypes.includes(nodeType)) {
// Swift uses class_declaration for both classes and structs
// Check for 'struct' child to differentiate
if (this.language === 'swift' && this.hasChildOfType(node, 'struct')) {
this.extractStruct(node);
} else if (this.language === 'swift' && this.hasChildOfType(node, 'enum')) {
this.extractEnum(node);
} else {
this.extractClass(node);
}
skipChildren = true; // extractClass visits body children
}
// Dart-specific: mixin and extension declarations treated as classes
else if (this.language === 'dart' && (nodeType === 'mixin_declaration' || nodeType === 'extension_declaration')) {
this.extractClass(node);
skipChildren = true;
}
// Check for method declarations (only if not already handled by functionTypes)
else if (this.extractor.methodTypes.includes(nodeType)) {
this.extractMethod(node);
skipChildren = true; // extractMethod visits children via visitFunctionBody
}
// Check for interface/protocol/trait declarations
else if (this.extractor.interfaceTypes.includes(nodeType)) {
this.extractInterface(node);
skipChildren = true; // extractInterface visits body children
}
// Check for struct declarations
else if (this.extractor.structTypes.includes(nodeType)) {
this.extractStruct(node);
skipChildren = true; // extractStruct visits body children
}
// Check for enum declarations
else if (this.extractor.enumTypes.includes(nodeType)) {
this.extractEnum(node);
skipChildren = true; // extractEnum visits body children
}
// Check for type alias declarations (e.g. `type X = ...` in TypeScript)
else if (this.extractor.typeAliasTypes.includes(nodeType)) {
this.extractTypeAlias(node);
}
// Check for variable declarations (const, let, var, etc.)
// Only extract top-level variables (not inside functions/methods)
else if (this.extractor.variableTypes.includes(nodeType) && !this.isInsideClassLikeNode()) {
this.extractVariable(node);
skipChildren = true; // extractVariable handles children
}
// Check for export statements containing non-function variable declarations
// e.g. `export const X = create(...)`, `export const X = { ... }`
else if (nodeType === 'export_statement') {
this.extractExportedVariables(node);
// Don't skip children — still need to visit inner nodes (functions, calls, etc.)
}
// Check for imports
else if (this.extractor.importTypes.includes(nodeType)) {
this.extractImport(node);
}
// Check for function calls
else if (this.extractor.callTypes.includes(nodeType)) {
this.extractCall(node);
}
// Visit children (unless the extract method already visited them)
if (!skipChildren) {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child) {
this.visitNode(child);
}
}
}
}
/**
* Create a Node object
*/
private createNode(
kind: NodeKind,
name: string,
node: SyntaxNode,
extra?: Partial<Node>
): Node {
const id = generateNodeId(this.filePath, kind, name, node.startPosition.row + 1);
const newNode: Node = {
id,
kind,
name,
qualifiedName: this.buildQualifiedName(name),
filePath: this.filePath,
language: this.language,
startLine: node.startPosition.row + 1,
endLine: node.endPosition.row + 1,
startColumn: node.startPosition.column,
endColumn: node.endPosition.column,
updatedAt: Date.now(),
...extra,
};
this.nodes.push(newNode);
// Add containment edge from parent
if (this.nodeStack.length > 0) {
const parentId = this.nodeStack[this.nodeStack.length - 1];
if (parentId) {
this.edges.push({
source: parentId,
target: id,
kind: 'contains',
});
}
}
return newNode;
}
/**
* Build qualified name from node stack
*/
private buildQualifiedName(name: string): string {
// Get names from the node stack
const parts: string[] = [this.filePath];
for (const nodeId of this.nodeStack) {
const node = this.nodes.find((n) => n.id === nodeId);
if (node) {
parts.push(node.name);
}
}
parts.push(name);
return parts.join('::');
}
/**
* Check if a node has a child of a specific type
*/
private hasChildOfType(node: SyntaxNode, type: string): boolean {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === type) {
return true;
}
}
return false;
}
/**
* Check if the current node stack indicates we are inside a class-like node
* (class, struct, interface, trait). File nodes do not count as class-like.
*/
private isInsideClassLikeNode(): boolean {
if (this.nodeStack.length === 0) return false;
const parentId = this.nodeStack[this.nodeStack.length - 1];
if (!parentId) return false;
const parentNode = this.nodes.find((n) => n.id === parentId);
if (!parentNode) return false;
return (
parentNode.kind === 'class' ||
parentNode.kind === 'struct' ||
parentNode.kind === 'interface' ||
parentNode.kind === 'trait' ||
parentNode.kind === 'enum'
);
}
/**
* Extract a function
*/
private extractFunction(node: SyntaxNode): void {
if (!this.extractor) return;
let name = extractName(node, this.source, this.extractor);
// For arrow functions and function expressions assigned to variables,
// resolve the name from the parent variable_declarator.
// e.g. `export const useAuth = () => { ... }` — the arrow_function node
// has no `name` field; the name lives on the variable_declarator.
if (
name === '<anonymous>' &&
(node.type === 'arrow_function' || node.type === 'function_expression')
) {
const parent = node.parent;
if (parent?.type === 'variable_declarator') {
const varName = getChildByField(parent, 'name');
if (varName) {
name = getNodeText(varName, this.source);
}
}
}
if (name === '<anonymous>') return; // Skip anonymous functions
const docstring = getPrecedingDocstring(node, this.source);
const signature = this.extractor.getSignature?.(node, this.source);
const visibility = this.extractor.getVisibility?.(node);
const isExported = this.extractor.isExported?.(node, this.source);
const isAsync = this.extractor.isAsync?.(node);
const isStatic = this.extractor.isStatic?.(node);
const funcNode = this.createNode('function', name, node, {
docstring,
signature,
visibility,
isExported,
isAsync,
isStatic,
});
// Push to stack and visit body
this.nodeStack.push(funcNode.id);
// Dart: function_body is a next sibling of function_signature, not a child
const body = this.language === 'dart'
? node.nextNamedSibling?.type === 'function_body' ? node.nextNamedSibling : null
: getChildByField(node, this.extractor.bodyField);
if (body) {
this.visitFunctionBody(body, funcNode.id);
}
this.nodeStack.pop();
}
/**
* Extract a class
*/
private extractClass(node: SyntaxNode): void {
if (!this.extractor) return;
const name = extractName(node, this.source, this.extractor);
const docstring = getPrecedingDocstring(node, this.source);
const visibility = this.extractor.getVisibility?.(node);
const isExported = this.extractor.isExported?.(node, this.source);
const classNode = this.createNode('class', name, node, {
docstring,
visibility,
isExported,
});
// Extract extends/implements
this.extractInheritance(node, classNode.id);
// Push to stack and visit body
this.nodeStack.push(classNode.id);
let body = getChildByField(node, this.extractor.bodyField);
// Dart: mixin_declaration uses class_body, extension uses extension_body
if (!body && this.language === 'dart') {
body = node.namedChildren.find((c: SyntaxNode) =>
c.type === 'class_body' || c.type === 'extension_body'
) || null;
}
if (!body) body = node;
// Visit all children for methods and properties
for (let i = 0; i < body.namedChildCount; i++) {
const child = body.namedChild(i);
if (child) {
this.visitNode(child);
}
}
this.nodeStack.pop();
}
/**
* Extract a method
*/
private extractMethod(node: SyntaxNode): void {
if (!this.extractor) return;
// For most languages, only extract as method if inside a class-like node
// But Go methods are top-level with a receiver, so always treat them as methods
if (!this.isInsideClassLikeNode() && this.language !== 'go') {
// Not inside a class-like node and not Go, treat as function
this.extractFunction(node);
return;
}
const name = extractName(node, this.source, this.extractor);
const docstring = getPrecedingDocstring(node, this.source);
const signature = this.extractor.getSignature?.(node, this.source);
const visibility = this.extractor.getVisibility?.(node);
const isAsync = this.extractor.isAsync?.(node);
const isStatic = this.extractor.isStatic?.(node);
const methodNode = this.createNode('method', name, node, {
docstring,
signature,
visibility,
isAsync,
isStatic,
});
// Push to stack and visit body
this.nodeStack.push(methodNode.id);
// Dart: function_body is a next sibling of method_signature, not a child
const body = this.language === 'dart'
? node.nextNamedSibling?.type === 'function_body' ? node.nextNamedSibling : null
: getChildByField(node, this.extractor.bodyField);
if (body) {
this.visitFunctionBody(body, methodNode.id);
}
this.nodeStack.pop();
}
/**
* Extract an interface/protocol/trait
*/
private extractInterface(node: SyntaxNode): void {
if (!this.extractor) return;
const name = extractName(node, this.source, this.extractor);
const docstring = getPrecedingDocstring(node, this.source);
const isExported = this.extractor.isExported?.(node, this.source);
// Determine kind based on language
let kind: NodeKind = 'interface';
if (this.language === 'rust') kind = 'trait';
this.createNode(kind, name, node, {
docstring,
isExported,
});
}
/**
* Extract a struct
*/
private extractStruct(node: SyntaxNode): void {
if (!this.extractor) return;
const name = extractName(node, this.source, this.extractor);
const docstring = getPrecedingDocstring(node, this.source);
const visibility = this.extractor.getVisibility?.(node);
const isExported = this.extractor.isExported?.(node, this.source);
const structNode = this.createNode('struct', name, node, {
docstring,
visibility,
isExported,
});
// Push to stack for field extraction
this.nodeStack.push(structNode.id);
const body = getChildByField(node, this.extractor.bodyField) || node;
for (let i = 0; i < body.namedChildCount; i++) {
const child = body.namedChild(i);
if (child) {
this.visitNode(child);
}
}
this.nodeStack.pop();
}
/**
* Extract an enum
*/
private extractEnum(node: SyntaxNode): void {
if (!this.extractor) return;
const name = extractName(node, this.source, this.extractor);
const docstring = getPrecedingDocstring(node, this.source);
const visibility = this.extractor.getVisibility?.(node);
const isExported = this.extractor.isExported?.(node, this.source);
this.createNode('enum', name, node, {
docstring,
visibility,
isExported,
});
}
/**
* Extract a variable declaration (const, let, var, etc.)
*
* Extracts top-level and module-level variable declarations.
* Captures the variable name and first 100 chars of initializer in signature for searchability.
*/
private extractVariable(node: SyntaxNode): void {
if (!this.extractor) return;
// Different languages have different variable declaration structures
// TypeScript/JavaScript: lexical_declaration contains variable_declarator children
// Python: assignment has left (identifier) and right (value)
// Go: var_declaration, short_var_declaration, const_declaration
const isConst = this.extractor.isConst?.(node) ?? false;
const kind: NodeKind = isConst ? 'constant' : 'variable';
const docstring = getPrecedingDocstring(node, this.source);
const isExported = this.extractor.isExported?.(node, this.source) ?? false;
// Extract variable declarators based on language
if (this.language === 'typescript' || this.language === 'javascript' ||
this.language === 'tsx' || this.language === 'jsx') {
// Handle lexical_declaration and variable_declaration
// These contain one or more variable_declarator children
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'variable_declarator') {
const nameNode = getChildByField(child, 'name');
const valueNode = getChildByField(child, 'value');
if (nameNode) {
const name = getNodeText(nameNode, this.source);
// Arrow functions / function expressions: extract as function instead of variable
if (valueNode && (valueNode.type === 'arrow_function' || valueNode.type === 'function_expression')) {
this.extractFunction(valueNode);
continue;
}
// Capture first 100 chars of initializer for context (stored in signature for searchability)
const initValue = valueNode ? getNodeText(valueNode, this.source).slice(0, 100) : undefined;
const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined;
this.createNode(kind, name, child, {
docstring,
signature: initSignature,
isExported,
});
}
}
}
} else if (this.language === 'python' || this.language === 'ruby') {
// Python/Ruby assignment: left = right
const left = getChildByField(node, 'left') || node.namedChild(0);
const right = getChildByField(node, 'right') || node.namedChild(1);
if (left && left.type === 'identifier') {
const name = getNodeText(left, this.source);
// Skip if name starts with lowercase and looks like a function call result
// Python constants are usually UPPER_CASE
const initValue = right ? getNodeText(right, this.source).slice(0, 100) : undefined;
const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined;
this.createNode(kind, name, node, {
docstring,
signature: initSignature,
});
}
} else if (this.language === 'go') {
// Go: var_declaration, short_var_declaration, const_declaration
// These can have multiple identifiers on the left
const specs = node.namedChildren.filter(c =>
c.type === 'var_spec' || c.type === 'const_spec'
);
for (const spec of specs) {
const nameNode = spec.namedChild(0);
if (nameNode && nameNode.type === 'identifier') {
const name = getNodeText(nameNode, this.source);
const valueNode = spec.namedChildCount > 1 ? spec.namedChild(spec.namedChildCount - 1) : null;
const initValue = valueNode ? getNodeText(valueNode, this.source).slice(0, 100) : undefined;
const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined;
this.createNode(node.type === 'const_declaration' ? 'constant' : 'variable', name, spec, {
docstring,
signature: initSignature,
});
}
}
// Handle short_var_declaration (:=)
if (node.type === 'short_var_declaration') {
const left = getChildByField(node, 'left');
const right = getChildByField(node, 'right');
if (left) {
// Can be expression_list with multiple identifiers
const identifiers = left.type === 'expression_list'
? left.namedChildren.filter(c => c.type === 'identifier')
: [left];
for (const id of identifiers) {
const name = getNodeText(id, this.source);
const initValue = right ? getNodeText(right, this.source).slice(0, 100) : undefined;
const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined;
this.createNode('variable', name, node, {
docstring,
signature: initSignature,
});
}
}
}
} else {
// Generic fallback for other languages
// Try to find identifier children
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'identifier' || child?.type === 'variable_declarator') {
const name = child.type === 'identifier'
? getNodeText(child, this.source)
: extractName(child, this.source, this.extractor);
if (name && name !== '<anonymous>') {
this.createNode(kind, name, child, {
docstring,
isExported,
});
}
}
}
}
}
/**
* Extract a type alias (e.g. `export type X = ...` in TypeScript)
*/
private extractTypeAlias(node: SyntaxNode): void {
if (!this.extractor) return;
const name = extractName(node, this.source, this.extractor);
if (name === '<anonymous>') return;
const docstring = getPrecedingDocstring(node, this.source);
const isExported = this.extractor.isExported?.(node, this.source);
this.createNode('type_alias', name, node, {
docstring,
isExported,
});
}
/**
* Extract an exported variable declaration that isn't a function.
* Handles patterns like:
* export const X = create(...)
* export const X = { ... }
* export const X = [...]
* export const X = "value"
*
* This is called for `export_statement` nodes that contain a
* `lexical_declaration` with `variable_declarator` children whose
* values are NOT already handled by functionTypes (arrow_function,
* function_expression).
*/
private extractExportedVariables(exportNode: SyntaxNode): void {
if (!this.extractor) return;
// Find the lexical_declaration or variable_declaration child
for (let i = 0; i < exportNode.namedChildCount; i++) {
const decl = exportNode.namedChild(i);
if (!decl || (decl.type !== 'lexical_declaration' && decl.type !== 'variable_declaration')) {
continue;
}
// Iterate over each variable_declarator in the declaration
for (let j = 0; j < decl.namedChildCount; j++) {
const declarator = decl.namedChild(j);
if (!declarator || declarator.type !== 'variable_declarator') continue;
const nameNode = getChildByField(declarator, 'name');
if (!nameNode) continue;
const name = getNodeText(nameNode, this.source);
// Skip if the value is a function type — those are already handled
// by extractFunction via the functionTypes dispatch
const value = getChildByField(declarator, 'value');
if (value) {
const valueType = value.type;
if (
this.extractor.functionTypes.includes(valueType)
) {
continue; // Already handled by extractFunction
}
}
const docstring = getPrecedingDocstring(exportNode, this.source);
this.createNode('variable', name, declarator, {
docstring,
isExported: true,
});
}
}
}
/**
* Extract an import
*
* Creates an import node with the full import statement stored in signature for searchability.
* Also creates unresolved references for resolution purposes.
*/
private extractImport(node: SyntaxNode): void {
const importText = getNodeText(node, this.source).trim();
// Extract module/package name based on language
let moduleName = '';
if (this.language === 'typescript' || this.language === 'javascript' ||
this.language === 'tsx' || this.language === 'jsx') {
const source = getChildByField(node, 'source');
if (source) {
moduleName = getNodeText(source, this.source).replace(/['"]/g, '');
}
// Create import node with full statement as signature for searchability
if (moduleName) {
this.createNode('import', moduleName, node, {
signature: importText,
});
}
} else if (this.language === 'python') {
// Python has two import forms:
// 1. import_statement: import os, sys
// 2. import_from_statement: from os import path
if (node.type === 'import_from_statement') {
const moduleNode = getChildByField(node, 'module_name');
if (moduleNode) {
moduleName = getNodeText(moduleNode, this.source);
}
} else {
// import_statement - may have multiple modules
// Can be dotted_name (import os) or aliased_import (import numpy as np)
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'dotted_name') {
const name = getNodeText(child, this.source);
this.createNode('import', name, node, {
signature: importText,
});
} else if (child?.type === 'aliased_import') {
// Extract the module name from inside aliased_import
const dottedName = child.namedChildren.find(c => c.type === 'dotted_name');
if (dottedName) {
const name = getNodeText(dottedName, this.source);
this.createNode('import', name, node, {
signature: importText,
});
}
}
}
// Skip creating another node below if we handled import_statement
if (node.type === 'import_statement') {
return;
}
}
if (moduleName) {
this.createNode('import', moduleName, node, {
signature: importText,
});
}
} else if (this.language === 'go') {
// Go imports can be single or grouped
// Single: import "fmt" - uses import_spec directly as child
// Grouped: import ( "fmt" \n "os" ) - uses import_spec_list containing import_spec children
// Helper function to extract path from import_spec
const extractFromSpec = (spec: SyntaxNode): void => {
const stringLiteral = spec.namedChildren.find(c => c.type === 'interpreted_string_literal');
if (stringLiteral) {
const path = getNodeText(stringLiteral, this.source).replace(/['"]/g, '');
if (path) {
this.createNode('import', path, spec, {
signature: getNodeText(spec, this.source).trim(),
});
}
}
};
// Find import_spec_list for grouped imports
const importSpecList = node.namedChildren.find(c => c.type === 'import_spec_list');
if (importSpecList) {
// Grouped imports - iterate through import_spec children
const importSpecs = importSpecList.namedChildren.filter(c => c.type === 'import_spec');
for (const spec of importSpecs) {
extractFromSpec(spec);
}
} else {
// Single import: import "fmt" - import_spec is direct child
const importSpec = node.namedChildren.find(c => c.type === 'import_spec');
if (importSpec) {
extractFromSpec(importSpec);
}
}
return; // Go handled completely above
} else if (this.language === 'rust') {
// Rust use declarations
// use std::{ffi::OsStr, io}; -> scoped_use_list with identifier "std"
// use crate::error::Error; -> scoped_identifier starting with "crate"
// use super::utils; -> scoped_identifier starting with "super"
// Helper to get the root crate/module from a scoped path
const getRootModule = (scopedNode: SyntaxNode): string => {
// Recursively find the leftmost identifier/crate/super/self
const firstChild = scopedNode.namedChild(0);
if (!firstChild) return getNodeText(scopedNode, this.source);
if (firstChild.type === 'identifier' ||
firstChild.type === 'crate' ||
firstChild.type === 'super' ||
firstChild.type === 'self') {
return getNodeText(firstChild, this.source);
} else if (firstChild.type === 'scoped_identifier') {
return getRootModule(firstChild);
}
return getNodeText(firstChild, this.source);
};
// Find the use argument (scoped_use_list or scoped_identifier)
const useArg = node.namedChildren.find(c =>
c.type === 'scoped_use_list' ||
c.type === 'scoped_identifier' ||
c.type === 'use_list' ||
c.type === 'identifier'
);
if (useArg) {
moduleName = getRootModule(useArg);
this.createNode('import', moduleName, node, {
signature: importText,
});
}
return; // Rust handled completely above
} else if (this.language === 'swift') {
// Swift imports: import Foundation, @testable import Alamofire
// AST structure: import_declaration -> identifier -> simple_identifier
const identifier = node.namedChildren.find(c => c.type === 'identifier');
if (identifier) {
moduleName = getNodeText(identifier, this.source);
this.createNode('import', moduleName, node, {
signature: importText,
});
}
return; // Swift handled completely above
} else if (this.language === 'kotlin') {
// Kotlin imports: import java.io.IOException, import x.y.Z as Alias, import x.y.*
// AST structure: import_header -> identifier (dotted path)
const identifier = node.namedChildren.find(c => c.type === 'identifier');
if (identifier) {
moduleName = getNodeText(identifier, this.source);
this.createNode('import', moduleName, node, {
signature: importText,
});
}
return; // Kotlin handled completely above
} else if (this.language === 'java') {
// Java imports: import java.util.List, import static x.Y.method, import x.y.*
// AST structure: import_declaration -> scoped_identifier (dotted path)
const scopedId = node.namedChildren.find(c => c.type === 'scoped_identifier');
if (scopedId) {
moduleName = getNodeText(scopedId, this.source);
this.createNode('import', moduleName, node, {
signature: importText,
});
}
return; // Java handled completely above
} else if (this.language === 'csharp') {
// C# using directives: using System, using System.Collections.Generic, using static X, using Alias = X
// AST structure: using_directive -> qualified_name (dotted) or identifier (simple)
// For alias imports: identifier = qualified_name - we want the qualified_name
const qualifiedName = node.namedChildren.find(c => c.type === 'qualified_name');
if (qualifiedName) {
moduleName = getNodeText(qualifiedName, this.source);
} else {
// Simple namespace like "using System;" - get the first identifier
const identifier = node.namedChildren.find(c => c.type === 'identifier');
if (identifier) {
moduleName = getNodeText(identifier, this.source);
}
}
if (moduleName) {
this.createNode('import', moduleName, node, {
signature: importText,
});
}
return; // C# handled completely above
} else if (this.language === 'php') {
// PHP use declarations: use X\Y\Z, use X as Y, use function X\func, use X\{A, B}
// AST structure: namespace_use_declaration -> namespace_use_clause -> qualified_name or name
// Check for grouped imports first: use X\{A, B}
const namespacePrefix = node.namedChildren.find(c => c.type === 'namespace_name');
const useGroup = node.namedChildren.find(c => c.type === 'namespace_use_group');
if (namespacePrefix && useGroup) {
// Grouped import - create one import per item
const prefix = getNodeText(namespacePrefix, this.source);
const useClauses = useGroup.namedChildren.filter((c: SyntaxNode) => c.type === 'namespace_use_clause');
for (const clause of useClauses) {
const name = clause.namedChildren.find((c: SyntaxNode) => c.type === 'name');
if (name) {
const fullPath = `${prefix}\\${getNodeText(name, this.source)}`;
this.createNode('import', fullPath, node, {
signature: importText,
});
}
}
return;
}
// Single import - find namespace_use_clause
const useClause = node.namedChildren.find(c => c.type === 'namespace_use_clause');
if (useClause) {
// Look for qualified_name (full path) or name (simple)
const qualifiedName = useClause.namedChildren.find((c: SyntaxNode) => c.type === 'qualified_name');
if (qualifiedName) {
moduleName = getNodeText(qualifiedName, this.source);
} else {
const name = useClause.namedChildren.find((c: SyntaxNode) => c.type === 'name');
if (name) {
moduleName = getNodeText(name, this.source);
}
}
}
if (moduleName) {
this.createNode('import', moduleName, node, {
signature: importText,
});
}
return; // PHP handled completely above
} else if (this.language === 'ruby') {
// Ruby imports: require 'json', require_relative '../helper'
// AST structure: call -> identifier (require/require_relative) + argument_list -> string -> string_content
// Check if this is a require/require_relative call
const identifier = node.namedChildren.find(c => c.type === 'identifier');
if (!identifier) return;
const methodName = getNodeText(identifier, this.source);
if (methodName !== 'require' && methodName !== 'require_relative') {
return; // Not an import, skip
}
// Find the argument (string)
const argList = node.namedChildren.find(c => c.type === 'argument_list');
if (argList) {
const stringNode = argList.namedChildren.find((c: SyntaxNode) => c.type === 'string');
if (stringNode) {
// Get string_content (without quotes)
const stringContent = stringNode.namedChildren.find((c: SyntaxNode) => c.type === 'string_content');
if (stringContent) {
moduleName = getNodeText(stringContent, this.source);
}
}
}
if (moduleName) {
this.createNode('import', moduleName, node, {
signature: importText,
});
}
return; // Ruby handled completely above
} else if (this.language === 'dart') {
// Dart imports: import 'dart:async'; import 'package:foo/bar.dart' as bar;
// AST: import_or_export -> library_import -> import_specification -> configurable_uri -> uri -> string_literal
const libraryImport = node.namedChildren.find(c => c.type === 'library_import');
if (libraryImport) {
const importSpec = libraryImport.namedChildren.find((c: SyntaxNode) => c.type === 'import_specification');
if (importSpec) {
const configurableUri = importSpec.namedChildren.find((c: SyntaxNode) => c.type === 'configurable_uri');
if (configurableUri) {
const uri = configurableUri.namedChildren.find((c: SyntaxNode) => c.type === 'uri');
if (uri) {
const stringLiteral = uri.namedChildren.find((c: SyntaxNode) => c.type === 'string_literal');
if (stringLiteral) {
moduleName = getNodeText(stringLiteral, this.source).replace(/['"]/g, '');
}
}
}
}
}
// Also handle exports: export 'src/foo.dart';
const libraryExport = node.namedChildren.find(c => c.type === 'library_export');
if (libraryExport) {
const configurableUri = libraryExport.namedChildren.find((c: SyntaxNode) => c.type === 'configurable_uri');
if (configurableUri) {
const uri = configurableUri.namedChildren.find((c: SyntaxNode) => c.type === 'uri');
if (uri) {
const stringLiteral = uri.namedChildren.find((c: SyntaxNode) => c.type === 'string_literal');
if (stringLiteral) {
moduleName = getNodeText(stringLiteral, this.source).replace(/['"]/g, '');
}
}
}
}
if (moduleName) {
this.createNode('import', moduleName, node, {
signature: importText,
});
}
return; // Dart handled completely above
} else if (this.language === 'c' || this.language === 'cpp') {
// C/C++ includes: #include <iostream>, #include "myheader.h"
// AST: preproc_include -> system_lib_string (<...>) or string_literal ("...")
// Check for system include: <path>
const systemLib = node.namedChildren.find(c => c.type === 'system_lib_string');
if (systemLib) {
// Remove angle brackets: <iostream> -> iostream
moduleName = getNodeText(systemLib, this.source).replace(/^<|>$/g, '');
} else {
// Check for local include: "path"
const stringLiteral = node.namedChildren.find(c => c.type === 'string_literal');
if (stringLiteral) {
const stringContent = stringLiteral.namedChildren.find((c: SyntaxNode) => c.type === 'string_content');
if (stringContent) {
moduleName = getNodeText(stringContent, this.source);
}
}
}
if (moduleName) {
this.createNode('import', moduleName, node, {
signature: importText,
});
}
return; // C/C++ handled completely above
} else {
// Generic extraction for other languages
moduleName = importText;
if (moduleName) {
this.createNode('import', moduleName, node, {
signature: importText,
});
}
}
// Keep unresolved reference creation for resolution purposes
// This is used to resolve imports to their target files/modules
if (moduleName && this.nodeStack.length > 0) {
const parentId = this.nodeStack[this.nodeStack.length - 1];
if (parentId) {
this.unresolvedReferences.push({
fromNodeId: parentId,
referenceName: moduleName,
referenceKind: 'imports',
line: node.startPosition.row + 1,
column: node.startPosition.column,
});
}
}
}
/**
* Extract a function call
*/
private extractCall(node: SyntaxNode): void {
if (this.nodeStack.length === 0) return;
const callerId = this.nodeStack[this.nodeStack.length - 1];
if (!callerId) return;
// Get the function/method being called
let calleeName = '';
const func = getChildByField(node, 'function') || node.namedChild(0);
if (func) {
if (func.type === 'member_expression' || func.type === 'attribute') {
// Method call: obj.method()
const property = getChildByField(func, 'property') || func.namedChild(1);
if (property) {
calleeName = getNodeText(property, this.source);
}
} else if (func.type === 'scoped_identifier' || func.type === 'scoped_call_expression') {
// Scoped call: Module::function()
calleeName = getNodeText(func, this.source);
} else {
calleeName = getNodeText(func, this.source);
}
}
if (calleeName) {
this.unresolvedReferences.push({
fromNodeId: callerId,
referenceName: calleeName,
referenceKind: 'calls',
line: node.startPosition.row + 1,
column: node.startPosition.column,
});
}
}
/**
* Visit function body and extract calls
*/
private visitFunctionBody(body: SyntaxNode, _functionId: string): void {
if (!this.extractor) return;
// Recursively find all call expressions
const visitForCalls = (node: SyntaxNode): void => {
if (this.extractor!.callTypes.includes(node.type)) {
this.extractCall(node);
}
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child) {
visitForCalls(child);
}
}
};
visitForCalls(body);
}
/**
* Extract inheritance relationships
*/
private extractInheritance(node: SyntaxNode, classId: string): void {
// Look for extends/implements clauses
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (
child.type === 'extends_clause' ||
child.type === 'class_heritage' ||
child.type === 'superclass'
) {
// Extract parent class name
const superclass = child.namedChild(0);
if (superclass) {
const name = getNodeText(superclass, this.source);
this.unresolvedReferences.push({
fromNodeId: classId,
referenceName: name,
referenceKind: 'extends',
line: child.startPosition.row + 1,
column: child.startPosition.column,
});
}
}
if (
child.type === 'implements_clause' ||
child.type === 'class_interface_clause' ||
child.type === 'interfaces' // Dart
) {
// Extract implemented interfaces
for (let j = 0; j < child.namedChildCount; j++) {
const iface = child.namedChild(j);
if (iface) {
const name = getNodeText(iface, this.source);
this.unresolvedReferences.push({
fromNodeId: classId,
referenceName: name,
referenceKind: 'implements',
line: iface.startPosition.row + 1,
column: iface.startPosition.column,
});
}
}
}
}
}
}
/**
* LiquidExtractor - Extracts relationships from Liquid template files
*
* Liquid is a templating language (used by Shopify, Jekyll, etc.) that doesn't
* have traditional functions or classes. Instead, we extract:
* - Section references ({% section 'name' %})
* - Snippet references ({% render 'name' %} and {% include 'name' %})
* - Schema blocks ({% schema %}...{% endschema %})
*/
export class LiquidExtractor {
private filePath: string;
private source: string;
private nodes: Node[] = [];
private edges: Edge[] = [];
private unresolvedReferences: UnresolvedReference[] = [];
private errors: ExtractionError[] = [];
constructor(filePath: string, source: string) {
this.filePath = filePath;
this.source = source;
}
/**
* Extract from Liquid source
*/
extract(): ExtractionResult {
const startTime = Date.now();
try {
// Create file node
const fileNode = this.createFileNode();
// Extract render/include statements (snippet references)
this.extractSnippetReferences(fileNode.id);
// Extract section references
this.extractSectionReferences(fileNode.id);
// Extract schema block
this.extractSchema(fileNode.id);
// Extract assign statements as variables
this.extractAssignments(fileNode.id);
} catch (error) {
captureException(error, { operation: 'liquid-extraction', filePath: this.filePath });
this.errors.push({
message: `Liquid extraction error: ${error instanceof Error ? error.message : String(error)}`,
severity: 'error',
});
}
return {
nodes: this.nodes,
edges: this.edges,
unresolvedReferences: this.unresolvedReferences,
errors: this.errors,
durationMs: Date.now() - startTime,
};
}
/**
* Create a file node for the Liquid template
*/
private createFileNode(): Node {
const lines = this.source.split('\n');
const id = generateNodeId(this.filePath, 'file', this.filePath, 1);
const fileNode: Node = {
id,
kind: 'file',
name: this.filePath.split('/').pop() || this.filePath,
qualifiedName: this.filePath,
filePath: this.filePath,
language: 'liquid',
startLine: 1,
endLine: lines.length,
startColumn: 0,
endColumn: lines[lines.length - 1]?.length || 0,
updatedAt: Date.now(),
};
this.nodes.push(fileNode);
return fileNode;
}
/**
* Extract {% render 'snippet' %} and {% include 'snippet' %} references
*/
private extractSnippetReferences(fileNodeId: string): void {
// Match {% render 'name' %} or {% include 'name' %} with optional parameters
const renderRegex = /\{%[-]?\s*(render|include)\s+['"]([^'"]+)['"]/g;
let match;
while ((match = renderRegex.exec(this.source)) !== null) {
const [fullMatch, tagType, snippetName] = match;
const line = this.getLineNumber(match.index);
// Create an import node for searchability
const importNodeId = generateNodeId(this.filePath, 'import', snippetName!, line);
const importNode: Node = {
id: importNodeId,
kind: 'import',
name: snippetName!,
qualifiedName: `${this.filePath}::import:${snippetName}`,
filePath: this.filePath,
language: 'liquid',
signature: fullMatch,
startLine: line,
endLine: line,
startColumn: match.index - this.getLineStart(line),
endColumn: match.index - this.getLineStart(line) + fullMatch.length,
updatedAt: Date.now(),
};
this.nodes.push(importNode);
// Add containment edge from file to import
this.edges.push({
source: fileNodeId,
target: importNodeId,
kind: 'contains',
});
// Create a component node for the snippet reference
const nodeId = generateNodeId(this.filePath, 'component', `${tagType}:${snippetName}`, line);
const node: Node = {
id: nodeId,
kind: 'component',
name: snippetName!,
qualifiedName: `${this.filePath}::${tagType}:${snippetName}`,
filePath: this.filePath,
language: 'liquid',
startLine: line,
endLine: line,
startColumn: match.index - this.getLineStart(line),
endColumn: match.index - this.getLineStart(line) + fullMatch.length,
updatedAt: Date.now(),
};
this.nodes.push(node);
// Add containment edge from file
this.edges.push({
source: fileNodeId,
target: nodeId,
kind: 'contains',
});
// Add unresolved reference to the snippet file
this.unresolvedReferences.push({
fromNodeId: fileNodeId,
referenceName: `snippets/${snippetName}.liquid`,
referenceKind: 'references',
line,
column: match.index - this.getLineStart(line),
});
}
}
/**
* Extract {% section 'name' %} references
*/
private extractSectionReferences(fileNodeId: string): void {
// Match {% section 'name' %}
const sectionRegex = /\{%[-]?\s*section\s+['"]([^'"]+)['"]/g;
let match;
while ((match = sectionRegex.exec(this.source)) !== null) {
const [fullMatch, sectionName] = match;
const line = this.getLineNumber(match.index);
// Create an import node for searchability
const importNodeId = generateNodeId(this.filePath, 'import', sectionName!, line);
const importNode: Node = {
id: importNodeId,
kind: 'import',
name: sectionName!,
qualifiedName: `${this.filePath}::import:${sectionName}`,
filePath: this.filePath,
language: 'liquid',
signature: fullMatch,
startLine: line,
endLine: line,
startColumn: match.index - this.getLineStart(line),
endColumn: match.index - this.getLineStart(line) + fullMatch.length,
updatedAt: Date.now(),
};
this.nodes.push(importNode);
// Add containment edge from file to import
this.edges.push({
source: fileNodeId,
target: importNodeId,
kind: 'contains',
});
// Create a component node for the section reference
const nodeId = generateNodeId(this.filePath, 'component', `section:${sectionName}`, line);
const node: Node = {
id: nodeId,
kind: 'component',
name: sectionName!,
qualifiedName: `${this.filePath}::section:${sectionName}`,
filePath: this.filePath,
language: 'liquid',
startLine: line,
endLine: line,
startColumn: match.index - this.getLineStart(line),
endColumn: match.index - this.getLineStart(line) + fullMatch.length,
updatedAt: Date.now(),
};
this.nodes.push(node);
// Add containment edge from file
this.edges.push({
source: fileNodeId,
target: nodeId,
kind: 'contains',
});
// Add unresolved reference to the section file
this.unresolvedReferences.push({
fromNodeId: fileNodeId,
referenceName: `sections/${sectionName}.liquid`,
referenceKind: 'references',
line,
column: match.index - this.getLineStart(line),
});
}
}
/**
* Extract {% schema %}...{% endschema %} blocks
*/
private extractSchema(fileNodeId: string): void {
// Match {% schema %}...{% endschema %}
const schemaRegex = /\{%[-]?\s*schema\s*[-]?%\}([\s\S]*?)\{%[-]?\s*endschema\s*[-]?%\}/g;
let match;
while ((match = schemaRegex.exec(this.source)) !== null) {
const [fullMatch, schemaContent] = match;
const startLine = this.getLineNumber(match.index);
const endLine = this.getLineNumber(match.index + fullMatch.length);
// Try to parse the schema JSON to get the name
let schemaName = 'schema';
try {
const schemaJson = JSON.parse(schemaContent!);
if (schemaJson.name) {
schemaName = schemaJson.name;
}
} catch {
// Schema isn't valid JSON, use default name
}
// Create a node for the schema
const nodeId = generateNodeId(this.filePath, 'constant', `schema:${schemaName}`, startLine);
const node: Node = {
id: nodeId,
kind: 'constant',
name: schemaName,
qualifiedName: `${this.filePath}::schema:${schemaName}`,
filePath: this.filePath,
language: 'liquid',
startLine,
endLine,
startColumn: match.index - this.getLineStart(startLine),
endColumn: 0,
docstring: schemaContent?.trim().substring(0, 200), // Store first 200 chars as docstring
updatedAt: Date.now(),
};
this.nodes.push(node);
// Add containment edge from file
this.edges.push({
source: fileNodeId,
target: nodeId,
kind: 'contains',
});
}
}
/**
* Extract {% assign var = value %} statements
*/
private extractAssignments(fileNodeId: string): void {
// Match {% assign variable_name = ... %}
const assignRegex = /\{%[-]?\s*assign\s+(\w+)\s*=/g;
let match;
while ((match = assignRegex.exec(this.source)) !== null) {
const [, variableName] = match;
const line = this.getLineNumber(match.index);
// Create a variable node
const nodeId = generateNodeId(this.filePath, 'variable', variableName!, line);
const node: Node = {
id: nodeId,
kind: 'variable',
name: variableName!,
qualifiedName: `${this.filePath}::${variableName}`,
filePath: this.filePath,
language: 'liquid',
startLine: line,
endLine: line,
startColumn: match.index - this.getLineStart(line),
endColumn: match.index - this.getLineStart(line) + match[0].length,
updatedAt: Date.now(),
};
this.nodes.push(node);
// Add containment edge from file
this.edges.push({
source: fileNodeId,
target: nodeId,
kind: 'contains',
});
}
}
/**
* Get the line number for a character index
*/
private getLineNumber(index: number): number {
const substring = this.source.substring(0, index);
return (substring.match(/\n/g) || []).length + 1;
}
/**
* Get the character index of the start of a line
*/
private getLineStart(lineNumber: number): number {
const lines = this.source.split('\n');
let index = 0;
for (let i = 0; i < lineNumber - 1 && i < lines.length; i++) {
index += lines[i]!.length + 1; // +1 for newline
}
return index;
}
}
/**
* SvelteExtractor - Extracts code relationships from Svelte component files
*
* Svelte files are multi-language (script + template + style). Rather than
* parsing the full Svelte grammar, we extract the <script> block content
* and delegate it to the TypeScript/JavaScript TreeSitterExtractor.
*
* Every .svelte file produces a component node (Svelte components are always importable).
*/
export class SvelteExtractor {
private filePath: string;
private source: string;
private nodes: Node[] = [];
private edges: Edge[] = [];
private unresolvedReferences: UnresolvedReference[] = [];
private errors: ExtractionError[] = [];
constructor(filePath: string, source: string) {
this.filePath = filePath;
this.source = source;
}
/**
* Extract from Svelte source
*/
extract(): ExtractionResult {
const startTime = Date.now();
try {
// Create component node for the .svelte file itself
const componentNode = this.createComponentNode();
// Extract and process script blocks
const scriptBlocks = this.extractScriptBlocks();
for (const block of scriptBlocks) {
this.processScriptBlock(block, componentNode.id);
}
} catch (error) {
captureException(error, { operation: 'svelte-extraction', filePath: this.filePath });
this.errors.push({
message: `Svelte extraction error: ${error instanceof Error ? error.message : String(error)}`,
severity: 'error',
});
}
return {
nodes: this.nodes,
edges: this.edges,
unresolvedReferences: this.unresolvedReferences,
errors: this.errors,
durationMs: Date.now() - startTime,
};
}
/**
* Create a component node for the .svelte file
*/
private createComponentNode(): Node {
const lines = this.source.split('\n');
const fileName = this.filePath.split(/[/\\]/).pop() || this.filePath;
const componentName = fileName.replace(/\.svelte$/, '');
const id = generateNodeId(this.filePath, 'component', componentName, 1);
const node: Node = {
id,
kind: 'component',
name: componentName,
qualifiedName: `${this.filePath}::${componentName}`,
filePath: this.filePath,
language: 'svelte',
startLine: 1,
endLine: lines.length,
startColumn: 0,
endColumn: lines[lines.length - 1]?.length || 0,
isExported: true, // Svelte components are always importable
updatedAt: Date.now(),
};
this.nodes.push(node);
return node;
}
/**
* Extract <script> blocks from the Svelte source
*/
private extractScriptBlocks(): Array<{
content: string;
startLine: number;
isModule: boolean;
isTypeScript: boolean;
}> {
const blocks: Array<{
content: string;
startLine: number;
isModule: boolean;
isTypeScript: boolean;
}> = [];
const scriptRegex = /<script(\s[^>]*)?>(?<content>[\s\S]*?)<\/script>/g;
let match;
while ((match = scriptRegex.exec(this.source)) !== null) {
const attrs = match[1] || '';
const content = match.groups?.content || match[2] || '';
// Detect TypeScript from lang attribute
const isTypeScript = /lang\s*=\s*["'](ts|typescript)["']/.test(attrs);
// Detect module script
const isModule = /context\s*=\s*["']module["']/.test(attrs);
// Calculate start line of the script content (line after <script>)
const beforeScript = this.source.substring(0, match.index);
const scriptTagLine = (beforeScript.match(/\n/g) || []).length;
// The content starts on the line after the opening <script> tag
const openingTag = match[0].substring(0, match[0].indexOf('>') + 1);
const openingTagLines = (openingTag.match(/\n/g) || []).length;
const contentStartLine = scriptTagLine + openingTagLines + 1; // 0-indexed line
blocks.push({
content,
startLine: contentStartLine,
isModule,
isTypeScript,
});
}
return blocks;
}
/**
* Process a script block by delegating to TreeSitterExtractor
*/
private processScriptBlock(
block: { content: string; startLine: number; isModule: boolean; isTypeScript: boolean },
componentNodeId: string
): void {
const scriptLanguage: Language = block.isTypeScript ? 'typescript' : 'javascript';
// Check if the script language parser is available
if (!isLanguageSupported(scriptLanguage)) {
this.errors.push({
message: `Parser for ${scriptLanguage} not available, cannot parse Svelte script block`,
severity: 'warning',
});
return;
}
// Delegate to TreeSitterExtractor
const extractor = new TreeSitterExtractor(this.filePath, block.content, scriptLanguage);
const result = extractor.extract();
// Offset line numbers from script block back to .svelte file positions
for (const node of result.nodes) {
node.startLine += block.startLine;
node.endLine += block.startLine;
node.language = 'svelte'; // Mark as svelte, not TS/JS
this.nodes.push(node);
// Add containment edge from component to this node
this.edges.push({
source: componentNodeId,
target: node.id,
kind: 'contains',
});
}
// Offset edges (they reference line numbers)
for (const edge of result.edges) {
if (edge.line) {
edge.line += block.startLine;
}
this.edges.push(edge);
}
// Offset unresolved references
for (const ref of result.unresolvedReferences) {
ref.line += block.startLine;
ref.filePath = this.filePath;
ref.language = 'svelte';
this.unresolvedReferences.push(ref);
}
// Carry over errors
for (const error of result.errors) {
if (error.line) {
error.line += block.startLine;
}
this.errors.push(error);
}
}
}
/**
* Extract nodes and edges from source code
*/
export function extractFromSource(
filePath: string,
source: string,
language?: Language
): ExtractionResult {
const detectedLanguage = language || detectLanguage(filePath);
// Use custom extractor for Svelte
if (detectedLanguage === 'svelte') {
const extractor = new SvelteExtractor(filePath, source);
return extractor.extract();
}
// Use custom extractor for Liquid
if (detectedLanguage === 'liquid') {
const extractor = new LiquidExtractor(filePath, source);
return extractor.extract();
}
const extractor = new TreeSitterExtractor(filePath, source, detectedLanguage);
return extractor.extract();
}