tree-hugger-js
Version:
A friendly tree-sitter wrapper for JavaScript and TypeScript
246 lines (208 loc) • 7.12 kB
text/typescript
import Parser from 'tree-sitter';
import { readFileSync } from 'fs';
import { TreeHuggerOptions, FunctionInfo, ClassInfo } from './types';
import { detectLanguage, getLanguageByName } from './languages';
import { TreeNode } from './node-wrapper';
import { Transform } from './transform';
import { ParseError, LanguageError } from './errors';
import { ScopeAnalyzer, Visitor, VisitorFunction } from './visitor';
export class TreeHugger {
private parser: Parser;
private tree: Parser.Tree;
private sourceCode: string;
public root: TreeNode;
constructor(sourceCode: string, options: TreeHuggerOptions = {}) {
this.parser = new Parser();
this.sourceCode = sourceCode;
// Detect or use specified language
const language = options.language
? getLanguageByName(options.language)
: detectLanguage(sourceCode);
if (!language) {
const message = options.language
? `Unknown language: ${options.language}`
: 'Could not detect language. Please specify language option.';
throw new LanguageError(message, sourceCode.slice(0, 100));
}
try {
this.parser.setLanguage(language.parser);
this.tree = this.parser.parse(sourceCode);
// Don't throw on syntax errors - tree-sitter can handle partial parsing
// Users can check tree.root.hasError if they want to know about errors
// Robust rootNode initialization with retry mechanism for CI environments
const rootNode = this.getRootNodeWithRetry(this.tree, 3);
this.root = new TreeNode(rootNode, sourceCode);
} catch (error) {
if (error instanceof ParseError || error instanceof LanguageError) throw error;
throw new ParseError(
`Failed to parse: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Robust rootNode getter with retry mechanism to handle race conditions in CI environments
* Addresses the known issue where tree.rootNode can be undefined in concurrent testing scenarios
*/
private getRootNodeWithRetry(tree: Parser.Tree, maxRetries: number = 3): Parser.SyntaxNode {
let attempts = 0;
while (attempts < maxRetries) {
const rootNode = tree.rootNode;
if (typeof rootNode?.type !== 'undefined') {
return rootNode;
}
attempts++;
if (attempts < maxRetries) {
// Short delay to allow native binding to stabilize
// This addresses the race condition documented in tree-sitter/node-tree-sitter#181
const delay = Math.min(10 * attempts, 50); // Progressive delay: 10ms, 20ms, 50ms
// Use synchronous delay to avoid async complications in constructor
const start = Date.now();
while (Date.now() - start < delay) {
// Busy wait for very short delays
}
}
}
throw new ParseError(
`Failed to get valid rootNode after ${maxRetries} attempts. This is likely caused by a race condition in tree-sitter native bindings. ` +
'Consider running tests serially or using a Jest moduleNameMapper workaround for tree-sitter.'
);
}
private findFirstError(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
if (node.type === 'ERROR') return node;
for (const child of node.children) {
const error = this.findFirstError(child);
if (error) return error;
}
return null;
}
// Delegate common methods to root
find(pattern: string): TreeNode | null {
return this.root.find(pattern);
}
findAll(pattern: string): TreeNode[] {
return this.root.findAll(pattern);
}
functions(): TreeNode[] {
return this.root.functions();
}
classes(): TreeNode[] {
return this.root.classes();
}
imports(): TreeNode[] {
return this.root.imports();
}
variables(): TreeNode[] {
return this.root.variables();
}
comments(): TreeNode[] {
return this.root.comments();
}
exports(): TreeNode[] {
return this.root.exports();
}
// JSX helpers
jsxComponents(): TreeNode[] {
return this.root.jsxComponents();
}
jsxProps(componentName?: string): TreeNode[] {
return this.root.jsxProps(componentName);
}
hooks(): TreeNode[] {
return this.root.hooks();
}
// Visitor pattern
visit(visitor: Visitor | VisitorFunction): void {
this.root.visit(visitor);
}
// Scope analysis
analyzeScopes(): ScopeAnalyzer {
const analyzer = new ScopeAnalyzer();
analyzer.analyze(this.root);
return analyzer;
}
// Find node at position
nodeAt(line: number, column: number): TreeNode | null {
return this.root.nodeAt(line, column);
}
// Enhanced analysis methods that return structured data
/**
* Get detailed function information with parameters and body ranges
*/
getFunctionDetails(): FunctionInfo[] {
const functions = this.functions();
return functions.map(fn => ({
name: fn.name,
type: fn.type,
async: fn.isAsync(),
parameters: fn.extractParameters(),
startLine: fn.line,
endLine: fn.endLine,
text: fn.text,
bodyRange: fn.getBodyRange() ?? undefined,
}));
}
/**
* Get detailed class information with methods and body ranges
*/
getClassDetails(): ClassInfo[] {
const classes = this.classes();
return classes.map(cls => {
const methods = cls.findAll('method_definition');
const methodDetails = methods.map(method => ({
name: method.name,
type: method.type,
async: method.isAsync(),
parameters: method.extractParameters(),
startLine: method.line,
endLine: method.endLine,
text: method.text,
bodyRange: method.getBodyRange() ?? undefined,
}));
return {
name: cls.name,
methods: methodDetails,
properties: cls.findAll('field_definition').map(prop => prop.name ?? 'unknown'),
startLine: cls.line,
endLine: cls.endLine,
text: cls.text,
bodyRange: cls.getBodyRange() ?? undefined,
};
});
}
// Transform API
transform(): Transform {
return new Transform(this.root, this.sourceCode);
}
}
// Main entry point functions
export function parse(filenameOrCode: string, options?: TreeHuggerOptions): TreeHugger {
let sourceCode: string;
// Check if it's a file path or raw code
if (
filenameOrCode.endsWith('.js') ||
filenameOrCode.endsWith('.ts') ||
filenameOrCode.endsWith('.jsx') ||
filenameOrCode.endsWith('.tsx') ||
filenameOrCode.endsWith('.mjs') ||
filenameOrCode.endsWith('.cjs')
) {
try {
sourceCode = readFileSync(filenameOrCode, 'utf-8');
// Use filename for better language detection
if (!options?.language) {
const lang = detectLanguage(filenameOrCode);
if (lang) {
options = { ...options, language: lang.name };
}
}
} catch {
// If file read fails, treat as source code
sourceCode = filenameOrCode;
}
} else {
sourceCode = filenameOrCode;
}
return new TreeHugger(sourceCode, options);
}
export { TreeNode } from './node-wrapper';
export * from './types';