UNPKG

tree-hugger-js

Version:

A friendly tree-sitter wrapper for JavaScript and TypeScript

457 lines 16.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.PatternParser = void 0; // Intuitive aliases that map to actual tree-sitter node types const NODE_TYPE_ALIASES = { // Functions function: ['function_declaration', 'function_expression', 'arrow_function', 'method_definition'], arrow: ['arrow_function'], method: ['method_definition'], // Classes and interfaces class: ['class_declaration', 'class_expression'], interface: ['interface_declaration'], // Variables variable: ['variable_declarator'], const: ['lexical_declaration'], let: ['lexical_declaration'], var: ['variable_declaration'], // Strings string: ['string', 'template_string'], template: ['template_string'], // Loops loop: [ 'for_statement', 'while_statement', 'do_statement', 'for_in_statement', 'for_of_statement', ], for: ['for_statement', 'for_in_statement', 'for_of_statement'], while: ['while_statement', 'do_statement'], // Conditionals condition: ['if_statement', 'switch_statement', 'ternary_expression'], if: ['if_statement'], switch: ['switch_statement'], ternary: ['ternary_expression'], // Imports/Exports import: ['import_statement'], export: ['export_statement'], // JSX jsx: ['jsx_element', 'jsx_self_closing_element', 'jsx_fragment'], 'jsx-element': ['jsx_element', 'jsx_self_closing_element'], 'jsx-attribute': ['jsx_attribute'], // Comments comment: ['comment'], // Calls call: ['call_expression'], new: ['new_expression'], // Returns return: ['return_statement'], throw: ['throw_statement'], // Common patterns statement: ['expression_statement', 'block_statement', 'empty_statement'], block: ['block_statement', 'statement_block'], }; class PatternParser { constructor() { this.pos = 0; this.input = ''; } parse(pattern) { this.input = pattern.trim(); this.pos = 0; // Handle empty pattern if (!this.input) { return () => false; } try { const selector = this.parseSelector(); return this.compilePredicate(selector); } catch { // Check if this might be a typo const suggestions = this.getSuggestions(pattern); if (suggestions.length > 0) { // eslint-disable-next-line no-console console.warn(`Unknown pattern '${pattern}'. Did you mean: ${suggestions.join(', ')}?`); } // For invalid patterns, return a predicate that matches nothing return () => false; } } getSuggestions(pattern) { const suggestions = []; // Check for common typos const lowerPattern = pattern.toLowerCase(); // Check aliases for (const alias of Object.keys(NODE_TYPE_ALIASES)) { if (alias.includes(lowerPattern) || lowerPattern.includes(alias)) { suggestions.push(alias); } } // Common mistakes const commonMistakes = { 'async-function': ['function[async]', 'arrow[async]'], async_function: ['function[async]', 'arrow[async]'], func: ['function'], str: ['string'], tpl: ['template'], cls: ['class'], }; if (commonMistakes[lowerPattern]) { suggestions.push(...commonMistakes[lowerPattern]); } return suggestions.slice(0, 3); // Return top 3 suggestions } parseSelector() { const selectors = []; while (this.pos < this.input.length) { // Skip whitespace this.skipWhitespace(); if (this.pos >= this.input.length) break; // Check for combinators if (this.peek() === '>') { this.pos++; this.skipWhitespace(); const right = this.parseSimpleSelector(); const left = selectors.pop(); if (!left) throw new Error('Invalid child combinator'); selectors.push({ type: 'child', left, right }); } else if (this.peek() === ',') { this.pos++; continue; // Will be handled at the end } else { const selector = this.parseSimpleSelector(); // Check if next is a descendant (space) this.skipWhitespace(); if (this.pos < this.input.length && this.peek() !== '>' && this.peek() !== ',') { const right = this.parseSimpleSelector(); selectors.push({ type: 'descendant', left: selector, right }); } else { selectors.push(selector); } } } if (selectors.length === 1) { return selectors[0]; } return { type: 'combination', selectors }; } parseSimpleSelector() { let selector = null; // Parse type selector if (this.isIdentifierStart()) { const type = this.parseIdentifier(); selector = { type: 'type', value: type }; } // Parse attributes and pseudo-selectors while (this.pos < this.input.length) { if (this.peek() === '[') { const attr = this.parseAttribute(); if (!selector) { selector = attr; } else { selector = { type: 'combination', selectors: [selector, attr] }; } } else if (this.peek() === ':') { const pseudo = this.parsePseudo(); if (!selector) { selector = pseudo; } else { selector = { type: 'combination', selectors: [selector, pseudo] }; } } else { break; } } if (!selector) { throw new Error('Expected selector'); } return selector; } parseAttribute() { this.expect('['); this.skipWhitespace(); const name = this.parseIdentifier(); this.skipWhitespace(); let operator = '='; let value; if (this.peek() === '=') { this.pos++; value = this.parseAttributeValue(); } else if (this.peek() === '~' && this.peekNext() === '=') { operator = '~='; this.pos += 2; value = this.parseAttributeValue(); } else if (this.peek() === '^' && this.peekNext() === '=') { operator = '^='; this.pos += 2; value = this.parseAttributeValue(); } else if (this.peek() === '$' && this.peekNext() === '=') { operator = '$='; this.pos += 2; value = this.parseAttributeValue(); } else if (this.peek() === '*' && this.peekNext() === '=') { operator = '*='; this.pos += 2; value = this.parseAttributeValue(); } this.skipWhitespace(); this.expect(']'); return { type: 'attribute', name, operator, value }; } parsePseudo() { this.expect(':'); const name = this.parseIdentifier(); // Handle pseudo-selectors with arguments if (this.peek() === '(') { this.pos++; const value = this.parseBalanced(')'); this.expect(')'); return { type: 'pseudo', name, value }; } return { type: 'pseudo', name }; } parseAttributeValue() { this.skipWhitespace(); if (this.peek() === '"' || this.peek() === "'") { const quote = this.peek(); this.pos++; const value = this.parseUntil(quote); this.expect(quote); return value; } return this.parseIdentifier(); } compilePredicate(selector) { switch (selector.type) { case 'type': // Check if this is an alias const typeValue = selector.value ?? ''; const aliasedTypes = NODE_TYPE_ALIASES[typeValue]; if (aliasedTypes) { // Match any of the aliased types return (node) => aliasedTypes.includes(node.type); } else { // Direct type match return (node) => node.type === typeValue; } case 'attribute': return this.compileAttributePredicate(selector); case 'pseudo': return this.compilePseudoPredicate(selector); case 'child': return (node) => { if (!selector.right || !selector.left) return false; const rightPred = this.compilePredicate(selector.right); if (!rightPred(node)) return false; const parent = node.parent; if (!parent) return false; const leftPred = this.compilePredicate(selector.left); return leftPred(parent); }; case 'descendant': return (node) => { if (!selector.right || !selector.left) return false; const rightPred = this.compilePredicate(selector.right); if (!rightPred(node)) return false; const leftPred = this.compilePredicate(selector.left); let current = node.parent; while (current) { if (leftPred(current)) return true; current = current.parent; } return false; }; case 'combination': if (!selector.selectors) return () => false; const predicates = selector.selectors.map(s => this.compilePredicate(s)); return (node) => predicates.every(p => p(node)); default: return () => false; } } compileAttributePredicate(selector) { const { name, operator, value } = selector; return (node) => { // Special attributes if (name === 'name') { const nodeName = node.name; if (!nodeName) { // For jsx_attribute, the name is in property_identifier child if (node.type === 'jsx_attribute') { // The first child should be the property_identifier const firstChild = node.children[0]; if (firstChild && firstChild.type === 'property_identifier') { return this.matchValue(firstChild.text, operator ?? '=', value); } } return false; } return this.matchValue(nodeName, operator ?? '=', value); } if (name === 'async') { return node.text.includes('async'); } if (name === 'text') { return this.matchValue(node.text, operator ?? '=', value); } // Field-based attributes if (!name) return false; const field = node.node.childForFieldName?.(name); if (field) { const fieldText = node.sourceCode.slice(field.startIndex, field.endIndex); return this.matchValue(fieldText, operator ?? '=', value); } return false; }; } compilePseudoPredicate(selector) { const { name, value } = selector; switch (name) { case 'has': if (!value) return () => false; const innerPredicate = new PatternParser().parse(value); return (node) => { // Check if any descendant matches (not just direct children) const checkDescendants = (n) => { if (innerPredicate(n)) return true; return n.children.some(child => checkDescendants(child)); }; return node.children.some(child => checkDescendants(child)); }; case 'not': if (!value) return () => false; const notPredicate = new PatternParser().parse(value); return (node) => !notPredicate(node); default: return () => false; } } matchValue(actual, operator, expected) { if (!expected) return true; if (!actual) return false; switch (operator) { case '=': return actual === expected; case '~=': return actual.split(/\s+/).includes(expected); case '^=': return actual.startsWith(expected); case '$=': return actual.endsWith(expected); case '*=': return actual.includes(expected); default: return false; } } // Parsing helpers skipWhitespace() { while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) { this.pos++; } } peek() { return this.input[this.pos] || ''; } peekNext() { return this.input[this.pos + 1] || ''; } expect(char) { if (this.peek() !== char) { throw new Error(`Expected '${char}' but got '${this.peek()}'`); } this.pos++; } isIdentifierStart() { const char = this.peek(); return /[a-zA-Z_]/.test(char); } parseIdentifier() { let result = ''; while (this.pos < this.input.length && /[a-zA-Z0-9_-]/.test(this.peek())) { result += this.peek(); this.pos++; } return result; } parseUntil(char) { let result = ''; while (this.pos < this.input.length && this.peek() !== char) { if (this.peek() === '\\' && this.pos + 1 < this.input.length) { // Handle escaped characters this.pos++; // Skip backslash if (this.peek() === char) { // Escaped quote - add the quote character result += char; } else { // Other escaped characters - keep the backslash and character result += '\\' + this.peek(); } } else { result += this.peek(); } this.pos++; } return result; } parseBalanced(closeChar) { let result = ''; let depth = 0; const openChar = closeChar === ')' ? '(' : closeChar === ']' ? '[' : closeChar; while (this.pos < this.input.length) { const current = this.peek(); if (current === openChar) { depth++; result += current; } else if (current === closeChar) { if (depth === 0) { // Found the matching closing character break; } else { depth--; result += current; } } else { result += current; } this.pos++; } return result; } } exports.PatternParser = PatternParser; //# sourceMappingURL=pattern-parser.js.map