mcp-adr-analysis-server
Version:
MCP server for analyzing Architectural Decision Records and project architecture
269 lines • 9.3 kB
JavaScript
/**
* Ripgrep wrapper for efficient code searching
* Provides a Node.js interface to the ripgrep command-line tool
*/
import { exec } from 'node:child_process';
import { promisify } from 'util';
import { FileSystemError } from '../types/index.js';
const execAsync = promisify(exec);
/**
* Check if ripgrep is available on the system
*/
export async function isRipgrepAvailable() {
try {
const { stdout } = await execAsync('rg --version');
return stdout.includes('ripgrep');
}
catch {
return false;
}
}
/**
* Search for patterns using ripgrep
*
* @param options - Search options
* @returns Promise resolving to array of file paths containing matches
*/
export async function searchWithRipgrep(options) {
const { pattern, path: searchPath, gitignore = true, maxMatches, fileType, caseInsensitive = false, glob, excludeGlob, } = options;
try {
// Check if ripgrep is available
const available = await isRipgrepAvailable();
if (!available) {
console.warn('Ripgrep not available, falling back to basic search');
return [];
}
// Build ripgrep command
const args = [];
// Add search pattern (escape special characters)
const escapedPattern = escapeShellArg(pattern);
args.push(escapedPattern);
// Add search path
args.push(searchPath);
// Add flags
args.push('--files-with-matches'); // Only return file paths
args.push('--no-heading'); // Don't group matches by file
if (!gitignore) {
args.push('--no-ignore');
}
if (maxMatches) {
args.push(`--max-count=${maxMatches}`);
}
if (fileType) {
// Handle comma-separated file types (e.g., "ts,js,py" -> "--type=ts --type=js --type=py")
const types = fileType
.split(',')
.map(t => t.trim())
.filter(t => t);
for (const type of types) {
args.push(`--type=${type}`);
}
}
if (caseInsensitive) {
args.push('--ignore-case');
}
if (glob) {
args.push(`--glob=${escapeShellArg(glob)}`);
}
if (excludeGlob) {
args.push(`--glob=!${escapeShellArg(excludeGlob)}`);
}
// Execute ripgrep
const command = `rg ${args.join(' ')}`;
const { stdout } = await execAsync(command, {
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
});
// Parse results (one file path per line)
const files = stdout
.split('\n')
.filter(line => line.trim())
.map(line => line.trim());
return files;
}
catch (error) {
// Exit code 1 means no matches found (not an error)
if (error && typeof error === 'object' && 'code' in error && error.code === 1) {
return [];
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FileSystemError(`Ripgrep search failed: ${errorMessage}`, {
pattern,
path: searchPath,
});
}
}
/**
* Search for patterns with detailed results
*
* @param options - Search options
* @returns Promise resolving to detailed search results
*/
export async function searchWithRipgrepDetailed(options) {
const { pattern, path: searchPath, gitignore = true, maxMatches, fileType, caseInsensitive = false, includeLineNumbers = true, contextBefore = 0, contextAfter = 0, glob, excludeGlob, } = options;
try {
// Check if ripgrep is available
const available = await isRipgrepAvailable();
if (!available) {
console.warn('Ripgrep not available, falling back to basic search');
return [];
}
// Build ripgrep command
const args = [];
// Add search pattern
const escapedPattern = escapeShellArg(pattern);
args.push(escapedPattern);
// Add search path
args.push(searchPath);
// Add flags for detailed output
args.push('--json'); // JSON output for easy parsing
args.push('--no-heading');
if (!gitignore) {
args.push('--no-ignore');
}
if (maxMatches) {
args.push(`--max-count=${maxMatches}`);
}
if (fileType) {
// Handle comma-separated file types (e.g., "ts,js,py" -> "--type=ts --type=js --type=py")
const types = fileType
.split(',')
.map(t => t.trim())
.filter(t => t);
for (const type of types) {
args.push(`--type=${type}`);
}
}
if (caseInsensitive) {
args.push('--ignore-case');
}
if (includeLineNumbers) {
args.push('--line-number');
args.push('--column');
}
if (contextBefore > 0) {
args.push(`--before-context=${contextBefore}`);
}
if (contextAfter > 0) {
args.push(`--after-context=${contextAfter}`);
}
if (glob) {
args.push(`--glob=${escapeShellArg(glob)}`);
}
if (excludeGlob) {
args.push(`--glob=!${escapeShellArg(excludeGlob)}`);
}
// Execute ripgrep
const command = `rg ${args.join(' ')}`;
const { stdout } = await execAsync(command, {
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
});
// Parse JSON results
const results = [];
const lines = stdout.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const data = JSON.parse(line);
if (data.type === 'match') {
const result = {
file: data.data.path.text,
match: data.data.lines.text,
};
if (includeLineNumbers) {
result.line = data.data.line_number;
result.column = data.data.absolute_offset;
}
results.push(result);
}
}
catch {
// Skip lines that aren't valid JSON
}
}
return results;
}
catch (error) {
// Exit code 1 means no matches found (not an error)
if (error && typeof error === 'object' && 'code' in error && error.code === 1) {
return [];
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FileSystemError(`Ripgrep detailed search failed: ${errorMessage}`, {
pattern,
path: searchPath,
});
}
}
/**
* Search for multiple patterns in parallel
*
* @param patterns - Array of patterns to search for
* @param searchPath - Path to search in
* @param options - Additional search options
* @returns Promise resolving to map of pattern to files
*/
export async function searchMultiplePatterns(patterns, searchPath, options = {}) {
const results = new Map();
// Check if ripgrep is available
const available = await isRipgrepAvailable();
if (!available) {
console.warn('Ripgrep not available, returning empty results');
patterns.forEach(pattern => results.set(pattern, []));
return results;
}
// Search for each pattern in parallel
const searchPromises = patterns.map(async (pattern) => {
const files = await searchWithRipgrep({
...options,
pattern,
path: searchPath,
});
return { pattern, files };
});
const searchResults = await Promise.all(searchPromises);
// Build the results map
for (const { pattern, files } of searchResults) {
results.set(pattern, files);
}
return results;
}
/**
* Escape shell arguments to prevent command injection
*/
function escapeShellArg(arg) {
// Escape single quotes and wrap in single quotes
return `'${arg.replace(/'/g, "'\\''")}'`;
}
/**
* Get ripgrep version information
*/
export async function getRipgrepVersion() {
try {
const { stdout } = await execAsync('rg --version');
const match = stdout.match(/ripgrep (\d+\.\d+\.\d+)/);
return match && match[1] ? match[1] : null;
}
catch {
return null;
}
}
/**
* Fallback search using simple file reading (when ripgrep is not available)
*/
export async function fallbackSearch(pattern, searchPath, options = {}) {
// Import file-system utilities
const { findFiles } = await import('./file-system.js');
// Convert pattern to glob pattern
const globPattern = options.glob || '**/*';
// Find files
const { files } = await findFiles(searchPath, [globPattern], {
includeContent: true,
limit: 100, // Limit for performance
});
// Filter files that contain the pattern
const regex = new RegExp(pattern, options.caseInsensitive ? 'i' : '');
const matchingFiles = files
.filter(file => file.content && regex.test(file.content))
.map(file => file.path);
return matchingFiles;
}
//# sourceMappingURL=ripgrep-wrapper.js.map