tree-hugger-js
Version:
A friendly tree-sitter wrapper for JavaScript and TypeScript
426 lines • 18.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Transform = void 0;
const errors_1 = require("./errors");
class Transform {
constructor(root, sourceCode) {
this.root = root;
this.edits = [];
this.sourceCode = sourceCode;
}
// Replace all occurrences of a specific identifier
renameIdentifier(oldName, newName) {
const identifiers = this.root.findAll('identifier').filter(node => node.text === oldName);
identifiers.forEach(node => {
this.edits.push({
start: node.node.startIndex,
end: node.node.endIndex,
text: newName,
});
});
return this;
}
// Replace function/variable names intelligently (not in strings/comments)
rename(oldName, newName) {
// Find all identifiers that match
const nodes = this.root.descendants().filter(node => {
// Check if it's an identifier and matches our name
if (node.type === 'identifier' && node.text === oldName) {
// Make sure it's not inside a string or comment
let ancestor = node.parent;
while (ancestor) {
if (ancestor.type === 'string' ||
ancestor.type === 'comment' ||
ancestor.type === 'string_fragment' || // Template literal text content
ancestor.type === 'regex') {
return false;
}
ancestor = ancestor.parent;
}
return true;
}
// Also check property identifiers (e.g., obj.method)
if (node.type === 'property_identifier' && node.text === oldName) {
return true;
}
// Also check shorthand property patterns in destructuring
if (node.type === 'shorthand_property_identifier_pattern' && node.text === oldName) {
return true;
}
return false;
});
nodes.forEach(node => {
this.edits.push({
start: node.node.startIndex,
end: node.node.endIndex,
text: newName,
});
});
return this;
}
// Replace text in specific node types
replaceIn(nodeType, pattern, replacement) {
const nodes = this.root.findAll(nodeType);
nodes.forEach(node => {
const newText = node.text.replace(pattern, replacement);
if (newText !== node.text) {
this.edits.push({
start: node.node.startIndex,
end: node.node.endIndex,
text: newText,
});
}
});
return this;
}
// Remove nodes matching a pattern
remove(pattern) {
// Handle common text patterns by converting to proper selectors
let actualPattern = pattern;
// If pattern looks like a method call (contains dot), convert to call_expression search
if (pattern.includes('.') && !pattern.includes('[') && !pattern.includes(' ')) {
actualPattern = `call_expression[text*="${pattern}"]`;
}
// If pattern looks like a function call (ends with parentheses), search for it
else if (pattern.endsWith('()')) {
const funcName = pattern.slice(0, -2);
actualPattern = `call_expression[text*="${funcName}("]`;
}
const nodes = this.root.findAll(actualPattern);
nodes.forEach(node => {
// For statements, remove the entire line including newline
let start = node.node.startIndex;
let end = node.node.endIndex;
let nodeToRemove = node;
// Special case: if removing a variable_declarator that's the only one
// in its declaration, remove the whole declaration
if (node.type === 'variable_declarator' && node.parent) {
const declaration = node.parent;
const declarators = declaration.findAll('variable_declarator');
if (declarators.length === 1) {
// Remove the entire declaration instead
nodeToRemove = declaration;
start = declaration.node.startIndex;
end = declaration.node.endIndex;
}
}
// Check if we should remove the whole line
if (this.isStatement(nodeToRemove)) {
// Find start of line
while (start > 0 && this.sourceCode[start - 1] !== '\n') {
start--;
}
// Find end of line including newline
while (end < this.sourceCode.length && this.sourceCode[end] !== '\n') {
end++;
}
if (end < this.sourceCode.length && this.sourceCode[end] === '\n') {
end++;
}
}
this.edits.push({
start,
end,
text: '',
});
});
return this;
}
// Remove unused imports
removeUnusedImports() {
const imports = this.root.findAll('import_statement');
// Get all identifiers except those in import statements
const allIdentifiers = new Set(this.root
.findAll('identifier')
.filter(id => {
// Check if this identifier is part of an import statement
let parent = id.parent;
while (parent) {
if (parent.type === 'import_statement') {
return false;
}
parent = parent.parent;
}
return true;
})
.map(n => n.text));
imports.forEach(importNode => {
let hasUsedImport = false;
// Check default import
const defaultImport = importNode.find('import_clause > identifier');
if (defaultImport && allIdentifiers.has(defaultImport.text)) {
hasUsedImport = true;
}
// Check named imports
const namedImports = importNode.findAll('import_specifier');
namedImports.forEach(spec => {
const imported = spec.node.childForFieldName('name') ?? spec.node.childForFieldName('alias');
if (imported &&
allIdentifiers.has(this.sourceCode.slice(imported.startIndex, imported.endIndex))) {
hasUsedImport = true;
}
});
// Check namespace import
const namespaceImport = importNode.find('namespace_import > identifier');
if (namespaceImport && allIdentifiers.has(namespaceImport.text)) {
hasUsedImport = true;
}
if (!hasUsedImport) {
// Remove this specific import statement
const start = importNode.node.startIndex;
let end = importNode.node.endIndex;
// Include the newline after the import
if (end < this.sourceCode.length && this.sourceCode[end] === '\n') {
end++;
}
this.edits.push({
start,
end,
text: '',
});
}
});
return this;
}
// Insert text before/after nodes
insertBefore(pattern, text) {
const nodes = this.root.findAll(pattern);
nodes.forEach(node => {
let targetNode = node;
let insertText = text;
// For keywords, find the parent statement to insert before
const keywordTypes = ['const', 'let', 'var', 'return', 'if', 'for', 'while'];
if (keywordTypes.includes(node.type)) {
let parent = node.parent;
while (parent && !this.isStatement(parent)) {
parent = parent.parent;
}
if (parent) {
targetNode = parent;
}
}
// Smart formatting for statement-level constructs
if (this.isStatement(targetNode) ||
targetNode.type === 'function_declaration' ||
targetNode.type === 'method_definition' ||
targetNode.type === 'class_declaration') {
// Get current indentation level
const lineStart = this.findLineStart(targetNode.node.startIndex);
const indentation = this.extractIndentation(lineStart);
// Check if we're inside a class or other block
const contextIndentation = this.getContextualIndentation(targetNode);
// Format with proper indentation and line breaks
insertText = contextIndentation + text.trim() + '\n' + indentation;
}
this.edits.push({
start: targetNode.node.startIndex,
end: targetNode.node.startIndex,
text: insertText,
});
});
return this;
}
insertAfter(pattern, text) {
const nodes = this.root.findAll(pattern);
nodes.forEach(node => {
let targetNode = node;
let insertText = text;
// For keywords, find the parent statement to insert after
const keywordTypes = ['const', 'let', 'var', 'return', 'if', 'for', 'while'];
if (keywordTypes.includes(node.type)) {
// Find the containing statement
let parent = node.parent;
while (parent && !this.isStatement(parent)) {
parent = parent.parent;
}
if (parent) {
targetNode = parent;
}
}
// Smart formatting: if we're inserting after a statement-level construct,
// add appropriate line breaks and indentation
if (this.isStatement(targetNode) ||
targetNode.type === 'function_declaration' ||
targetNode.type === 'method_definition' ||
targetNode.type === 'class_declaration') {
// Check if there's already a newline after the target node
const nextCharIndex = targetNode.node.endIndex;
const hasNewlineAfter = nextCharIndex < this.sourceCode.length && this.sourceCode[nextCharIndex] === '\n';
// Get indentation level by looking at the target node's line
const lineStart = this.findLineStart(targetNode.node.startIndex);
const indentation = this.extractIndentation(lineStart);
// Special handling for method definitions in classes
if (targetNode.type === 'method_definition' && targetNode.parent?.type === 'class_body') {
// Insert with same indentation as other class methods
const methodIndentation = indentation;
if (!hasNewlineAfter) {
insertText = '\n\n' + methodIndentation + text.trim();
}
else {
// Check if there's already an empty line after the method
const nextNextChar = nextCharIndex + 1;
const hasEmptyLineAfter = nextNextChar < this.sourceCode.length && this.sourceCode[nextNextChar] === '\n';
if (!hasEmptyLineAfter) {
insertText = '\n' + methodIndentation + text.trim();
}
else {
insertText = methodIndentation + text.trim() + '\n';
}
}
}
else {
// Get contextual indentation for better placement
const contextIndentation = this.getContextualIndentation(targetNode);
// Format the insertion with proper line breaks and indentation
if (!hasNewlineAfter) {
// No newline after target, so add one, then our code with proper indentation
insertText = '\n' + contextIndentation + text.trim();
}
else {
// There's already a newline, insert with proper indentation and ensure clean spacing
insertText = contextIndentation + text.trim() + '\n';
}
}
}
this.edits.push({
start: targetNode.node.endIndex,
end: targetNode.node.endIndex,
text: insertText,
});
});
return this;
}
// Apply all edits and return the transformed code
toString() {
// Validate edits first
this.validateEdits();
// Sort edits by position (reverse order to apply from end to start)
const sortedEdits = [...this.edits].sort((a, b) => b.start - a.start);
let result = this.sourceCode;
// Apply each edit
sortedEdits.forEach(edit => {
if (edit.start < 0 || edit.end > this.sourceCode.length) {
throw new errors_1.TransformError(`Edit out of bounds: ${edit.start}-${edit.end} in source of length ${this.sourceCode.length}`, edit);
}
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
});
return result;
}
validateEdits() {
// Check for overlapping edits
const sortedEdits = [...this.edits].sort((a, b) => a.start - b.start);
for (let i = 0; i < sortedEdits.length - 1; i++) {
const current = sortedEdits[i];
const next = sortedEdits[i + 1];
if (current.end > next.start) {
throw new errors_1.TransformError(`Overlapping edits detected: [${current.start}-${current.end}] overlaps with [${next.start}-${next.end}]`, { current, next });
}
}
}
// Get the edits without applying them
getEdits() {
return [...this.edits];
}
isStatement(node) {
const statementTypes = [
'expression_statement',
'variable_declaration',
'lexical_declaration',
'import_statement',
'export_statement',
'return_statement',
'if_statement',
'for_statement',
'while_statement',
'function_declaration',
'class_declaration',
];
return statementTypes.includes(node.type);
}
findLineStart(index) {
let start = index;
while (start > 0 && this.sourceCode[start - 1] !== '\n') {
start--;
}
return start;
}
extractIndentation(lineStart) {
let indentation = '';
let i = lineStart;
while (i < this.sourceCode.length &&
(this.sourceCode[i] === ' ' || this.sourceCode[i] === '\t')) {
indentation += this.sourceCode[i];
i++;
}
return indentation;
}
getContextualIndentation(node) {
// Get the base indentation of the current node
const lineStart = this.findLineStart(node.node.startIndex);
const baseIndentation = this.extractIndentation(lineStart);
// Check if we're inside a class body, function body, or other block
let parent = node.parent;
while (parent) {
if (parent.type === 'class_body' ||
parent.type === 'function_body' ||
parent.type === 'statement_block' ||
parent.type === 'block') {
// We're inside a block, use the same indentation as sibling elements
return baseIndentation;
}
if (parent.type === 'class_declaration') {
// For method_definition inside a class, use class body indentation
if (node.type === 'method_definition') {
return baseIndentation;
}
// For inserting before/after a class, use class-level indentation
const classLineStart = this.findLineStart(parent.node.startIndex);
return this.extractIndentation(classLineStart);
}
parent = parent.parent;
}
// Default to the node's own indentation
return baseIndentation;
}
detectIndentationStyle() {
// Detect if the file uses tabs or spaces and how many spaces
const lines = this.sourceCode.split('\n');
let spaceCount = 0;
let tabCount = 0;
let spacesPerIndent = 2; // default
for (const line of lines) {
if (line.length > 0 && (line[0] === ' ' || line[0] === '\t')) {
if (line[0] === '\t') {
tabCount++;
}
else {
// Count consecutive spaces at start
let spaces = 0;
for (const char of line) {
if (char === ' ')
spaces++;
else
break;
}
if (spaces > 0) {
spaceCount++;
// Common indentation levels: 2, 4, 8
if (spaces % 4 === 0)
spacesPerIndent = 4;
else if (spaces % 2 === 0)
spacesPerIndent = 2;
}
}
}
}
// Return the detected style
if (tabCount > spaceCount) {
return '\t';
}
else {
return ' '.repeat(spacesPerIndent);
}
}
}
exports.Transform = Transform;
//# sourceMappingURL=transform.js.map