/** * Code Search Service * * Fast code search using ripgrep-like functionality */ import * as fs from 'fs/promises'; import * as path from 'path'; export interface SearchResult { file: string; line: number; column: number; match: string; context: string; } export interface SearchOptions { caseSensitive?: boolean; wholeWord?: boolean; regex?: boolean; filePattern?: string; maxResults?: number; contextLines?: number; } export class SearchService { private cwd: string; constructor(cwd: string = process.cwd()) { this.cwd = cwd; } /** * Search for pattern in code */ async search(pattern: string, options: SearchOptions = {}): Promise { const { caseSensitive = false, wholeWord = false, regex = false, filePattern = '**/*.{js,ts,jsx,tsx,py,java,go,rs,c,cpp,h,hpp}', maxResults = 100, contextLines = 2, } = options; const results: SearchResult[] = []; const files = await this.findFiles(filePattern); // Create search regex let searchPattern = regex ? pattern : this.escapeRegex(pattern); if (wholeWord) searchPattern = `\\b${searchPattern}\\b`; const flags = caseSensitive ? 'g' : 'gi'; const searchRegex = new RegExp(searchPattern, flags); // Search each file for (const file of files.slice(0, maxResults * 2)) { try { const content = await fs.readFile(file, 'utf-8'); const lines = content.split('\n'); lines.forEach((line, lineIndex) => { const matches = [...line.matchAll(searchRegex)]; matches.forEach(match => { if (results.length >= maxResults) return; // Get context const startLine = Math.max(0, lineIndex - contextLines); const endLine = Math.min(lines.length, lineIndex + contextLines + 1); const context = lines.slice(startLine, endLine).join('\n'); results.push({ file: path.relative(this.cwd, file), line: lineIndex + 1, column: match.index || 0, match: match[0], context, }); }); }); } catch { // Skip unreadable files continue; } } return results.slice(0, maxResults); } /** * Find files matching glob pattern */ private async findFiles(_pattern: string): Promise { const files: string[] = []; async function walk(dir: string, depth: number = 0): Promise { if (depth > 10) return; // Prevent infinite recursion try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); // Skip node_modules, .git, etc. if ( entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build' || entry.name.startsWith('.') ) { continue; } if (entry.isDirectory()) { await walk(fullPath, depth + 1); } else if (entry.isFile()) { files.push(fullPath); } } } catch { // Skip inaccessible directories } } await walk(this.cwd); return files; } /** * Escape regex special characters */ private escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Format search results for display */ formatResults(results: SearchResult[]): string { if (results.length === 0) { return 'No results found.'; } let output = `Found ${results.length} results:\n\n`; results.forEach((result, index) => { output += `${index + 1}. ${result.file}:${result.line}:${result.column}\n`; output += ` Match: "${result.match}"\n`; output += ` Context:\n`; result.context .split('\n') .forEach((line, i) => { const prefix = i === Math.floor(result.context.split('\n').length / 2) ? '→' : ' '; output += ` ${prefix} ${line}\n`; }); output += '\n'; }); return output; } }