|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import fs from 'fs'; |
|
|
import fsPromises from 'fs/promises'; |
|
|
import path from 'path'; |
|
|
import { EOL } from 'os'; |
|
|
import { spawn } from 'child_process'; |
|
|
import { globStream } from 'glob'; |
|
|
import { BaseTool, ToolResult } from './tools.js'; |
|
|
import { SchemaValidator } from '../utils/schemaValidator.js'; |
|
|
import { makeRelative, shortenPath } from '../utils/paths.js'; |
|
|
import { getErrorMessage, isNodeError } from '../utils/errors.js'; |
|
|
import { isGitRepository } from '../utils/gitUtils.js'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export interface GrepToolParams { |
|
|
|
|
|
|
|
|
|
|
|
pattern: string; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
path?: string; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
include?: string; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
interface GrepMatch { |
|
|
filePath: string; |
|
|
lineNumber: number; |
|
|
line: string; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export class GrepTool extends BaseTool<GrepToolParams, ToolResult> { |
|
|
static readonly Name = 'search_file_content'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
constructor(private rootDirectory: string) { |
|
|
super( |
|
|
GrepTool.Name, |
|
|
'SearchText', |
|
|
'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.', |
|
|
{ |
|
|
properties: { |
|
|
pattern: { |
|
|
description: |
|
|
"The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').", |
|
|
type: 'string', |
|
|
}, |
|
|
path: { |
|
|
description: |
|
|
'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.', |
|
|
type: 'string', |
|
|
}, |
|
|
include: { |
|
|
description: |
|
|
"Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).", |
|
|
type: 'string', |
|
|
}, |
|
|
}, |
|
|
required: ['pattern'], |
|
|
type: 'object', |
|
|
}, |
|
|
); |
|
|
|
|
|
this.rootDirectory = path.resolve(rootDirectory); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private resolveAndValidatePath(relativePath?: string): string { |
|
|
const targetPath = path.resolve(this.rootDirectory, relativePath || '.'); |
|
|
|
|
|
|
|
|
if ( |
|
|
!targetPath.startsWith(this.rootDirectory) && |
|
|
targetPath !== this.rootDirectory |
|
|
) { |
|
|
throw new Error( |
|
|
`Path validation failed: Attempted path "${relativePath || '.'}" resolves outside the allowed root directory "${this.rootDirectory}".`, |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
try { |
|
|
const stats = fs.statSync(targetPath); |
|
|
if (!stats.isDirectory()) { |
|
|
throw new Error(`Path is not a directory: ${targetPath}`); |
|
|
} |
|
|
} catch (error: unknown) { |
|
|
if (isNodeError(error) && error.code !== 'ENOENT') { |
|
|
throw new Error(`Path does not exist: ${targetPath}`); |
|
|
} |
|
|
throw new Error( |
|
|
`Failed to access path stats for ${targetPath}: ${error}`, |
|
|
); |
|
|
} |
|
|
|
|
|
return targetPath; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
validateToolParams(params: GrepToolParams): string | null { |
|
|
if ( |
|
|
this.schema.parameters && |
|
|
!SchemaValidator.validate( |
|
|
this.schema.parameters as Record<string, unknown>, |
|
|
params, |
|
|
) |
|
|
) { |
|
|
return 'Parameters failed schema validation.'; |
|
|
} |
|
|
|
|
|
try { |
|
|
new RegExp(params.pattern); |
|
|
} catch (error) { |
|
|
return `Invalid regular expression pattern provided: ${params.pattern}. Error: ${getErrorMessage(error)}`; |
|
|
} |
|
|
|
|
|
try { |
|
|
this.resolveAndValidatePath(params.path); |
|
|
} catch (error) { |
|
|
return getErrorMessage(error); |
|
|
} |
|
|
|
|
|
return null; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async execute( |
|
|
params: GrepToolParams, |
|
|
signal: AbortSignal, |
|
|
): Promise<ToolResult> { |
|
|
const validationError = this.validateToolParams(params); |
|
|
if (validationError) { |
|
|
return { |
|
|
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, |
|
|
returnDisplay: `Model provided invalid parameters. Error: ${validationError}`, |
|
|
}; |
|
|
} |
|
|
|
|
|
let searchDirAbs: string; |
|
|
try { |
|
|
searchDirAbs = this.resolveAndValidatePath(params.path); |
|
|
const searchDirDisplay = params.path || '.'; |
|
|
|
|
|
const matches: GrepMatch[] = await this.performGrepSearch({ |
|
|
pattern: params.pattern, |
|
|
path: searchDirAbs, |
|
|
include: params.include, |
|
|
signal, |
|
|
}); |
|
|
|
|
|
if (matches.length === 0) { |
|
|
const noMatchMsg = `No matches found for pattern "${params.pattern}" in path "${searchDirDisplay}"${params.include ? ` (filter: "${params.include}")` : ''}.`; |
|
|
return { llmContent: noMatchMsg, returnDisplay: `No matches found` }; |
|
|
} |
|
|
|
|
|
const matchesByFile = matches.reduce( |
|
|
(acc, match) => { |
|
|
const relativeFilePath = |
|
|
path.relative( |
|
|
searchDirAbs, |
|
|
path.resolve(searchDirAbs, match.filePath), |
|
|
) || path.basename(match.filePath); |
|
|
if (!acc[relativeFilePath]) { |
|
|
acc[relativeFilePath] = []; |
|
|
} |
|
|
acc[relativeFilePath].push(match); |
|
|
acc[relativeFilePath].sort((a, b) => a.lineNumber - b.lineNumber); |
|
|
return acc; |
|
|
}, |
|
|
{} as Record<string, GrepMatch[]>, |
|
|
); |
|
|
|
|
|
let llmContent = `Found ${matches.length} match(es) for pattern "${params.pattern}" in path "${searchDirDisplay}"${params.include ? ` (filter: "${params.include}")` : ''}:\n---\n`; |
|
|
|
|
|
for (const filePath in matchesByFile) { |
|
|
llmContent += `File: ${filePath}\n`; |
|
|
matchesByFile[filePath].forEach((match) => { |
|
|
const trimmedLine = match.line.trim(); |
|
|
llmContent += `L${match.lineNumber}: ${trimmedLine}\n`; |
|
|
}); |
|
|
llmContent += '---\n'; |
|
|
} |
|
|
|
|
|
return { |
|
|
llmContent: llmContent.trim(), |
|
|
returnDisplay: `Found ${matches.length} matche(s)`, |
|
|
}; |
|
|
} catch (error) { |
|
|
console.error(`Error during GrepLogic execution: ${error}`); |
|
|
const errorMessage = getErrorMessage(error); |
|
|
return { |
|
|
llmContent: `Error during grep search operation: ${errorMessage}`, |
|
|
returnDisplay: `Error: ${errorMessage}`, |
|
|
}; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private isCommandAvailable(command: string): Promise<boolean> { |
|
|
return new Promise((resolve) => { |
|
|
const checkCommand = process.platform === 'win32' ? 'where' : 'command'; |
|
|
const checkArgs = |
|
|
process.platform === 'win32' ? [command] : ['-v', command]; |
|
|
try { |
|
|
const child = spawn(checkCommand, checkArgs, { |
|
|
stdio: 'ignore', |
|
|
shell: process.platform === 'win32', |
|
|
}); |
|
|
child.on('close', (code) => resolve(code === 0)); |
|
|
child.on('error', () => resolve(false)); |
|
|
} catch { |
|
|
resolve(false); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private parseGrepOutput(output: string, basePath: string): GrepMatch[] { |
|
|
const results: GrepMatch[] = []; |
|
|
if (!output) return results; |
|
|
|
|
|
const lines = output.split(EOL); |
|
|
|
|
|
for (const line of lines) { |
|
|
if (!line.trim()) continue; |
|
|
|
|
|
|
|
|
const firstColonIndex = line.indexOf(':'); |
|
|
if (firstColonIndex === -1) continue; |
|
|
|
|
|
|
|
|
const secondColonIndex = line.indexOf(':', firstColonIndex + 1); |
|
|
if (secondColonIndex === -1) continue; |
|
|
|
|
|
|
|
|
const filePathRaw = line.substring(0, firstColonIndex); |
|
|
const lineNumberStr = line.substring( |
|
|
firstColonIndex + 1, |
|
|
secondColonIndex, |
|
|
); |
|
|
const lineContent = line.substring(secondColonIndex + 1); |
|
|
|
|
|
const lineNumber = parseInt(lineNumberStr, 10); |
|
|
|
|
|
if (!isNaN(lineNumber)) { |
|
|
const absoluteFilePath = path.resolve(basePath, filePathRaw); |
|
|
const relativeFilePath = path.relative(basePath, absoluteFilePath); |
|
|
|
|
|
results.push({ |
|
|
filePath: relativeFilePath || path.basename(absoluteFilePath), |
|
|
lineNumber, |
|
|
line: lineContent, |
|
|
}); |
|
|
} |
|
|
} |
|
|
return results; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getDescription(params: GrepToolParams): string { |
|
|
let description = `'${params.pattern}'`; |
|
|
if (params.include) { |
|
|
description += ` in ${params.include}`; |
|
|
} |
|
|
if (params.path) { |
|
|
const resolvedPath = path.resolve(this.rootDirectory, params.path); |
|
|
if (resolvedPath === this.rootDirectory || params.path === '.') { |
|
|
description += ` within ./`; |
|
|
} else { |
|
|
const relativePath = makeRelative(resolvedPath, this.rootDirectory); |
|
|
description += ` within ${shortenPath(relativePath)}`; |
|
|
} |
|
|
} |
|
|
return description; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async performGrepSearch(options: { |
|
|
pattern: string; |
|
|
path: string; |
|
|
include?: string; |
|
|
signal: AbortSignal; |
|
|
}): Promise<GrepMatch[]> { |
|
|
const { pattern, path: absolutePath, include } = options; |
|
|
let strategyUsed = 'none'; |
|
|
|
|
|
try { |
|
|
|
|
|
const isGit = isGitRepository(absolutePath); |
|
|
const gitAvailable = isGit && (await this.isCommandAvailable('git')); |
|
|
|
|
|
if (gitAvailable) { |
|
|
strategyUsed = 'git grep'; |
|
|
const gitArgs = [ |
|
|
'grep', |
|
|
'--untracked', |
|
|
'-n', |
|
|
'-E', |
|
|
'--ignore-case', |
|
|
pattern, |
|
|
]; |
|
|
if (include) { |
|
|
gitArgs.push('--', include); |
|
|
} |
|
|
|
|
|
try { |
|
|
const output = await new Promise<string>((resolve, reject) => { |
|
|
const child = spawn('git', gitArgs, { |
|
|
cwd: absolutePath, |
|
|
windowsHide: true, |
|
|
}); |
|
|
const stdoutChunks: Buffer[] = []; |
|
|
const stderrChunks: Buffer[] = []; |
|
|
|
|
|
child.stdout.on('data', (chunk) => stdoutChunks.push(chunk)); |
|
|
child.stderr.on('data', (chunk) => stderrChunks.push(chunk)); |
|
|
child.on('error', (err) => |
|
|
reject(new Error(`Failed to start git grep: ${err.message}`)), |
|
|
); |
|
|
child.on('close', (code) => { |
|
|
const stdoutData = Buffer.concat(stdoutChunks).toString('utf8'); |
|
|
const stderrData = Buffer.concat(stderrChunks).toString('utf8'); |
|
|
if (code === 0) resolve(stdoutData); |
|
|
else if (code === 1) |
|
|
resolve(''); |
|
|
else |
|
|
reject( |
|
|
new Error(`git grep exited with code ${code}: ${stderrData}`), |
|
|
); |
|
|
}); |
|
|
}); |
|
|
return this.parseGrepOutput(output, absolutePath); |
|
|
} catch (gitError: unknown) { |
|
|
console.debug( |
|
|
`GrepLogic: git grep failed: ${getErrorMessage(gitError)}. Falling back...`, |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const grepAvailable = await this.isCommandAvailable('grep'); |
|
|
if (grepAvailable) { |
|
|
strategyUsed = 'system grep'; |
|
|
const grepArgs = ['-r', '-n', '-H', '-E']; |
|
|
const commonExcludes = ['.git', 'node_modules', 'bower_components']; |
|
|
commonExcludes.forEach((dir) => grepArgs.push(`--exclude-dir=${dir}`)); |
|
|
if (include) { |
|
|
grepArgs.push(`--include=${include}`); |
|
|
} |
|
|
grepArgs.push(pattern); |
|
|
grepArgs.push('.'); |
|
|
|
|
|
try { |
|
|
const output = await new Promise<string>((resolve, reject) => { |
|
|
const child = spawn('grep', grepArgs, { |
|
|
cwd: absolutePath, |
|
|
windowsHide: true, |
|
|
}); |
|
|
const stdoutChunks: Buffer[] = []; |
|
|
const stderrChunks: Buffer[] = []; |
|
|
|
|
|
const onData = (chunk: Buffer) => stdoutChunks.push(chunk); |
|
|
const onStderr = (chunk: Buffer) => { |
|
|
const stderrStr = chunk.toString(); |
|
|
|
|
|
if ( |
|
|
!stderrStr.includes('Permission denied') && |
|
|
!/grep:.*: Is a directory/i.test(stderrStr) |
|
|
) { |
|
|
stderrChunks.push(chunk); |
|
|
} |
|
|
}; |
|
|
const onError = (err: Error) => { |
|
|
cleanup(); |
|
|
reject(new Error(`Failed to start system grep: ${err.message}`)); |
|
|
}; |
|
|
const onClose = (code: number | null) => { |
|
|
const stdoutData = Buffer.concat(stdoutChunks).toString('utf8'); |
|
|
const stderrData = Buffer.concat(stderrChunks) |
|
|
.toString('utf8') |
|
|
.trim(); |
|
|
cleanup(); |
|
|
if (code === 0) resolve(stdoutData); |
|
|
else if (code === 1) |
|
|
resolve(''); |
|
|
else { |
|
|
if (stderrData) |
|
|
reject( |
|
|
new Error( |
|
|
`System grep exited with code ${code}: ${stderrData}`, |
|
|
), |
|
|
); |
|
|
else resolve(''); |
|
|
} |
|
|
}; |
|
|
|
|
|
const cleanup = () => { |
|
|
child.stdout.removeListener('data', onData); |
|
|
child.stderr.removeListener('data', onStderr); |
|
|
child.removeListener('error', onError); |
|
|
child.removeListener('close', onClose); |
|
|
if (child.connected) { |
|
|
child.disconnect(); |
|
|
} |
|
|
}; |
|
|
|
|
|
child.stdout.on('data', onData); |
|
|
child.stderr.on('data', onStderr); |
|
|
child.on('error', onError); |
|
|
child.on('close', onClose); |
|
|
}); |
|
|
return this.parseGrepOutput(output, absolutePath); |
|
|
} catch (grepError: unknown) { |
|
|
console.debug( |
|
|
`GrepLogic: System grep failed: ${getErrorMessage(grepError)}. Falling back...`, |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
console.debug( |
|
|
'GrepLogic: Falling back to JavaScript grep implementation.', |
|
|
); |
|
|
strategyUsed = 'javascript fallback'; |
|
|
const globPattern = include ? include : '**/*'; |
|
|
const ignorePatterns = [ |
|
|
'.git/**', |
|
|
'node_modules/**', |
|
|
'bower_components/**', |
|
|
'.svn/**', |
|
|
'.hg/**', |
|
|
]; |
|
|
|
|
|
const filesStream = globStream(globPattern, { |
|
|
cwd: absolutePath, |
|
|
dot: true, |
|
|
ignore: ignorePatterns, |
|
|
absolute: true, |
|
|
nodir: true, |
|
|
signal: options.signal, |
|
|
}); |
|
|
|
|
|
const regex = new RegExp(pattern, 'i'); |
|
|
const allMatches: GrepMatch[] = []; |
|
|
|
|
|
for await (const filePath of filesStream) { |
|
|
const fileAbsolutePath = filePath as string; |
|
|
try { |
|
|
const content = await fsPromises.readFile(fileAbsolutePath, 'utf8'); |
|
|
const lines = content.split(/\r?\n/); |
|
|
lines.forEach((line, index) => { |
|
|
if (regex.test(line)) { |
|
|
allMatches.push({ |
|
|
filePath: |
|
|
path.relative(absolutePath, fileAbsolutePath) || |
|
|
path.basename(fileAbsolutePath), |
|
|
lineNumber: index + 1, |
|
|
line, |
|
|
}); |
|
|
} |
|
|
}); |
|
|
} catch (readError: unknown) { |
|
|
|
|
|
if (!isNodeError(readError) || readError.code !== 'ENOENT') { |
|
|
console.debug( |
|
|
`GrepLogic: Could not read/process ${fileAbsolutePath}: ${getErrorMessage(readError)}`, |
|
|
); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
return allMatches; |
|
|
} catch (error: unknown) { |
|
|
console.error( |
|
|
`GrepLogic: Error in performGrepSearch (Strategy: ${strategyUsed}): ${getErrorMessage(error)}`, |
|
|
); |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
} |
|
|
|