ripgrep.ts•10.7 kB
/**
* Ripgrep integration for fast code searching
*/
import { spawn } from 'child_process';
import path from 'path';
import { validatePath } from './lib.js';
export interface RipgrepSearchOptions {
pattern: string;
path?: string;
filePattern?: string; // -g flag
caseInsensitive?: boolean; // -i flag
contextLines?: number; // -C flag
maxResults?: number; // Results per page
page?: number; // Page number (1-based)
literalString?: boolean; // -F flag
wordBoundary?: boolean; // -w flag
}
export interface RipgrepMatch {
file: string;
line: number;
column: number;
matchedText: string;
contextBefore: string[];
contextAfter: string[];
}
export interface RipgrepResult {
query: {
pattern: string;
searchedPaths: string[];
options?: any;
};
summary: {
totalMatches: number;
filesWithMatches: number;
searchTimeMs: number;
};
pagination?: {
page: number;
pageSize: number;
totalPages: number;
hasMore: boolean;
};
matches: RipgrepMatch[];
ripgrepDetails: {
commandUsed: string;
explanation: string;
};
suggestions?: string[];
}
/**
* Execute ripgrep search with the given options
*/
export async function ripgrepSearch(
options: RipgrepSearchOptions,
allowedDirs: string[]
): Promise<RipgrepResult> {
const startTime = Date.now();
const args: string[] = ['--json', '--no-config'];
// Context lines
if (options.contextLines !== undefined && options.contextLines > 0) {
args.push('-C', options.contextLines.toString());
}
// Case sensitivity
if (options.caseInsensitive) {
args.push('-i');
}
// Literal string (not regex)
if (options.literalString) {
args.push('-F');
}
// Word boundary
if (options.wordBoundary) {
args.push('-w');
}
// File pattern (glob)
if (options.filePattern) {
// Support multiple patterns separated by comma
const patterns = options.filePattern.split(',').map(p => p.trim());
patterns.forEach(pattern => {
args.push('-g', pattern);
});
}
// Note: We don't use ripgrep's -m flag for pagination
// Instead, we collect ALL matches and paginate in memory
// This allows proper pagination across all results
// Pattern
args.push(options.pattern);
// Search paths (validated)
let searchPaths: string[];
if (options.path) {
try {
const validated = await validatePath(path.join(allowedDirs[0], options.path));
searchPaths = [validated];
} catch (error) {
throw new Error(`Invalid search path: ${error instanceof Error ? error.message : String(error)}`);
}
} else {
searchPaths = allowedDirs;
}
args.push(...searchPaths);
const result = await executeRipgrep(args, options.pattern, searchPaths);
result.summary.searchTimeMs = Date.now() - startTime;
// Apply pagination
const page = options.page || 1;
const pageSize = options.maxResults || 100;
const totalMatches = result.matches.length;
const startIdx = (page - 1) * pageSize;
const endIdx = startIdx + pageSize;
// Paginate matches
const paginatedMatches = result.matches.slice(startIdx, endIdx);
const totalPages = Math.ceil(totalMatches / pageSize);
result.matches = paginatedMatches;
result.pagination = {
page,
pageSize,
totalPages,
hasMore: endIdx < totalMatches
};
// Update summary to reflect total (not paginated)
result.summary.totalMatches = totalMatches;
// Add helpful suggestions
if (totalMatches === 0) {
result.suggestions = [
'No matches found. Try:',
'- Using case-insensitive search (caseInsensitive: true)',
'- Simplifying the pattern',
'- Checking if the file pattern is correct'
];
} else if (result.pagination.hasMore) {
result.suggestions = [
`Showing page ${page} of ${totalPages} (${paginatedMatches.length} of ${totalMatches} total matches)`,
`To see more results, request page ${page + 1}`,
'Or narrow your search with:',
'- More specific pattern',
'- File pattern filter (filePattern)',
'- Path restriction (path parameter)'
];
} else if (page > 1) {
result.suggestions = [
`Showing final page ${page} of ${totalPages} (${paginatedMatches.length} matches on this page, ${totalMatches} total)`
];
}
return result;
}
/**
* Search within a specific file using ripgrep
*/
export async function ripgrepSearchInFile(
filePath: string,
pattern: string,
options: {
caseInsensitive?: boolean;
contextLines?: number;
literalString?: boolean;
wordBoundary?: boolean;
} = {}
): Promise<RipgrepResult> {
const args: string[] = ['--json', '--no-config'];
if (options.contextLines !== undefined && options.contextLines > 0) {
args.push('-C', options.contextLines.toString());
}
if (options.caseInsensitive) {
args.push('-i');
}
if (options.literalString) {
args.push('-F');
}
if (options.wordBoundary) {
args.push('-w');
}
args.push(pattern);
args.push(filePath);
const startTime = Date.now();
const result = await executeRipgrep(args, pattern, [filePath]);
result.summary.searchTimeMs = Date.now() - startTime;
return result;
}
/**
* Find files by name pattern using ripgrep
*/
export async function ripgrepFindFiles(
pattern: string,
searchPath: string | undefined,
allowedDirs: string[]
): Promise<string[]> {
const args = ['--files', '--no-config'];
let searchPaths: string[];
if (searchPath) {
try {
const validated = await validatePath(path.join(allowedDirs[0], searchPath));
searchPaths = [validated];
} catch (error) {
throw new Error(`Invalid search path: ${error instanceof Error ? error.message : String(error)}`);
}
} else {
searchPaths = allowedDirs;
}
args.push(...searchPaths);
return new Promise((resolve, reject) => {
const rg = spawn('rg', args);
let stdout = '';
let stderr = '';
rg.stdout.on('data', (data) => stdout += data);
rg.stderr.on('data', (data) => stderr += data);
rg.on('close', (code) => {
if (code === 0 || code === 1) {
const allFiles = stdout.trim().split('\n').filter(f => f);
// Filter by pattern (simple glob matching)
const matching = allFiles.filter(file => {
const filename = path.basename(file);
return matchPattern(filename, pattern);
});
resolve(matching);
} else {
reject(new Error(`ripgrep failed: ${stderr}`));
}
});
rg.on('error', (error) => {
reject(new Error(`Failed to spawn ripgrep: ${error.message}`));
});
});
}
/**
* Execute ripgrep command and parse output
*/
async function executeRipgrep(args: string[], pattern: string, searchPaths: string[]): Promise<RipgrepResult> {
return new Promise((resolve, reject) => {
const rg = spawn('rg', args);
let stdout = '';
let stderr = '';
rg.stdout.on('data', (data) => stdout += data);
rg.stderr.on('data', (data) => stderr += data);
rg.on('close', (code) => {
if (code === 0 || code === 1) { // 0=found, 1=not found
try {
const result = parseRipgrepJsonOutput(stdout, pattern, args, searchPaths);
resolve(result);
} catch (err) {
reject(new Error(`Failed to parse ripgrep output: ${err instanceof Error ? err.message : String(err)}`));
}
} else {
reject(new Error(`ripgrep error (code ${code}): ${stderr}`));
}
});
rg.on('error', (error) => {
reject(new Error(`Failed to spawn ripgrep: ${error.message}. Make sure ripgrep is installed.`));
});
});
}
/**
* Parse ripgrep JSON output
*/
function parseRipgrepJsonOutput(jsonOutput: string, pattern: string, args: string[], searchPaths: string[]): RipgrepResult {
const lines = jsonOutput.trim().split('\n').filter(l => l);
const matches: RipgrepMatch[] = [];
let currentFile = '';
let contextBefore: string[] = [];
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.type === 'begin') {
currentFile = entry.data.path.text;
contextBefore = [];
} else if (entry.type === 'match') {
const matchedLine = entry.data.line_number;
matches.push({
file: currentFile,
line: matchedLine,
column: entry.data.submatches[0]?.start || 0,
matchedText: entry.data.lines.text.trimEnd(),
contextBefore: [...contextBefore],
contextAfter: []
});
contextBefore = [];
} else if (entry.type === 'context') {
const contextLine = entry.data.line_number;
const contextText = entry.data.lines.text.trimEnd();
if (matches.length === 0) {
// Context before first match
contextBefore.push(contextText);
} else {
const lastMatch = matches[matches.length - 1];
if (contextLine < lastMatch.line) {
// Context before this match (shouldn't happen with current logic)
contextBefore.push(contextText);
} else {
// Context after last match
lastMatch.contextAfter.push(contextText);
}
}
}
} catch {
// Skip malformed lines
}
}
const filesWithMatches = new Set(matches.map(m => m.file)).size;
return {
query: {
pattern,
searchedPaths: searchPaths
},
summary: {
totalMatches: matches.length,
filesWithMatches,
searchTimeMs: 0 // Will be set by caller
},
matches,
ripgrepDetails: {
commandUsed: `rg ${args.join(' ')}`,
explanation: `Searched for pattern: ${pattern}`
}
};
}
/**
* Match filename against glob pattern
*/
function matchPattern(filename: string, pattern: string): boolean {
// If pattern doesn't contain wildcards, add implicit wildcards for partial matching
const hasWildcards = pattern.includes('*') || pattern.includes('?') || pattern.includes('{');
if (!hasWildcards) {
// For patterns without wildcards, match if the pattern appears anywhere in the filename
return filename.toLowerCase().includes(pattern.toLowerCase());
}
// Convert glob pattern to regex
const regex = new RegExp(
'^' + pattern
.replace(/\./g, '\\.')
.replace(/\*/g, '.*')
.replace(/\?/g, '.')
.replace(/\{([^}]+)\}/g, (_, alternatives) => `(${alternatives.replace(/,/g, '|')})`)
+ '$',
'i'
);
return regex.test(filename);
}