cloudaxe-opencode/packages/opencode/src/tool/grep.ts

345 lines
8.8 KiB
TypeScript
Raw Normal View History

2025-05-31 18:41:00 +00:00
import { z } from "zod"
import { Tool } from "./tool"
import { App } from "../app/app"
import { spawn } from "child_process"
import { promises as fs } from "fs"
import path from "path"
2025-05-26 18:09:17 +00:00
const DESCRIPTION = `Fast content search tool that finds files containing specific text or patterns, returning matching file paths sorted by modification time (newest first).
WHEN TO USE THIS TOOL:
- Use when you need to find files containing specific text or patterns
- Great for searching code bases for function names, variable declarations, or error messages
- Useful for finding all files that use a particular API or pattern
HOW TO USE:
- Provide a regex pattern to search for within file contents
- Set literal_text=true if you want to search for the exact text with special characters (recommended for non-regex users)
- Optionally specify a starting directory (defaults to current working directory)
- Optionally provide an include pattern to filter which files to search
- Results are sorted with most recently modified files first
REGEX PATTERN SYNTAX (when literal_text=false):
- Supports standard regular expression syntax
- 'function' searches for the literal text "function"
- 'log\\..*Error' finds text starting with "log." and ending with "Error"
- 'import\\s+.*\\s+from' finds import statements in JavaScript/TypeScript
COMMON INCLUDE PATTERN EXAMPLES:
- '*.js' - Only search JavaScript files
- '*.{ts,tsx}' - Only search TypeScript files
- '*.go' - Only search Go files
LIMITATIONS:
- Results are limited to 100 files (newest first)
- Performance depends on the number of files being searched
- Very large binary files may be skipped
- Hidden files (starting with '.') are skipped
TIPS:
- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
- Always check if results are truncated and refine your search pattern if needed
2025-05-31 18:41:00 +00:00
- Use literal_text=true when searching for exact text containing special characters like dots, parentheses, etc.`
2025-05-26 18:09:17 +00:00
interface GrepMatch {
2025-05-31 18:41:00 +00:00
path: string
modTime: number
lineNum: number
lineText: string
2025-05-26 18:09:17 +00:00
}
function escapeRegexPattern(pattern: string): string {
const specialChars = [
"\\",
".",
"+",
"*",
"?",
"(",
")",
"[",
"]",
"{",
"}",
"^",
"$",
"|",
2025-05-31 18:41:00 +00:00
]
let escaped = pattern
2025-05-26 18:09:17 +00:00
for (const char of specialChars) {
2025-05-31 18:41:00 +00:00
escaped = escaped.replaceAll(char, "\\" + char)
2025-05-26 18:09:17 +00:00
}
2025-05-31 18:41:00 +00:00
return escaped
2025-05-26 18:09:17 +00:00
}
function globToRegex(glob: string): string {
2025-05-31 18:41:00 +00:00
let regexPattern = glob.replaceAll(".", "\\.")
regexPattern = regexPattern.replaceAll("*", ".*")
regexPattern = regexPattern.replaceAll("?", ".")
2025-05-26 18:09:17 +00:00
// Handle {a,b,c} patterns
regexPattern = regexPattern.replace(/\{([^}]+)\}/g, (_, inner) => {
2025-05-31 18:41:00 +00:00
return "(" + inner.replace(/,/g, "|") + ")"
})
2025-05-26 18:09:17 +00:00
2025-05-31 18:41:00 +00:00
return regexPattern
2025-05-26 18:09:17 +00:00
}
async function searchWithRipgrep(
pattern: string,
searchPath: string,
include?: string,
): Promise<GrepMatch[]> {
return new Promise((resolve, reject) => {
2025-05-31 18:41:00 +00:00
const args = ["-n", pattern]
2025-05-26 18:09:17 +00:00
if (include) {
2025-05-31 18:41:00 +00:00
args.push("--glob", include)
2025-05-26 18:09:17 +00:00
}
2025-05-31 18:41:00 +00:00
args.push(searchPath)
2025-05-26 18:09:17 +00:00
2025-05-31 18:41:00 +00:00
const rg = spawn("rg", args)
let output = ""
let errorOutput = ""
2025-05-26 18:09:17 +00:00
rg.stdout.on("data", (data) => {
2025-05-31 18:41:00 +00:00
output += data.toString()
})
2025-05-26 18:09:17 +00:00
rg.stderr.on("data", (data) => {
2025-05-31 18:41:00 +00:00
errorOutput += data.toString()
})
2025-05-26 18:09:17 +00:00
rg.on("close", async (code) => {
if (code === 1) {
// No matches found
2025-05-31 18:41:00 +00:00
resolve([])
return
2025-05-26 18:09:17 +00:00
}
if (code !== 0) {
2025-05-31 18:41:00 +00:00
reject(new Error(`ripgrep failed: ${errorOutput}`))
return
2025-05-26 18:09:17 +00:00
}
2025-05-31 18:41:00 +00:00
const lines = output.trim().split("\n")
const matches: GrepMatch[] = []
2025-05-26 18:09:17 +00:00
for (const line of lines) {
2025-05-31 18:41:00 +00:00
if (!line) continue
2025-05-26 18:09:17 +00:00
// Parse ripgrep output format: file:line:content
2025-05-31 18:41:00 +00:00
const parts = line.split(":", 3)
if (parts.length < 3) continue
2025-05-26 18:09:17 +00:00
2025-05-31 18:41:00 +00:00
const filePath = parts[0]
const lineNum = parseInt(parts[1], 10)
const lineText = parts[2]
2025-05-26 18:09:17 +00:00
try {
2025-05-31 18:41:00 +00:00
const stats = await fs.stat(filePath)
2025-05-26 18:09:17 +00:00
matches.push({
path: filePath,
modTime: stats.mtime.getTime(),
lineNum,
lineText,
2025-05-31 18:41:00 +00:00
})
2025-05-26 18:09:17 +00:00
} catch {
// Skip files we can't access
2025-05-31 18:41:00 +00:00
continue
2025-05-26 18:09:17 +00:00
}
}
2025-05-31 18:41:00 +00:00
resolve(matches)
})
2025-05-26 18:09:17 +00:00
rg.on("error", (err) => {
2025-05-31 18:41:00 +00:00
reject(err)
})
})
2025-05-26 18:09:17 +00:00
}
async function searchFilesWithRegex(
pattern: string,
rootPath: string,
include?: string,
): Promise<GrepMatch[]> {
2025-05-31 18:41:00 +00:00
const matches: GrepMatch[] = []
const regex = new RegExp(pattern)
2025-05-26 18:09:17 +00:00
2025-05-31 18:41:00 +00:00
let includePattern: RegExp | undefined
2025-05-26 18:09:17 +00:00
if (include) {
2025-05-31 18:41:00 +00:00
const regexPattern = globToRegex(include)
includePattern = new RegExp(regexPattern)
2025-05-26 18:09:17 +00:00
}
async function walkDir(dir: string) {
2025-05-31 18:41:00 +00:00
if (matches.length >= 200) return
2025-05-26 18:09:17 +00:00
try {
2025-05-31 18:41:00 +00:00
const entries = await fs.readdir(dir, { withFileTypes: true })
2025-05-26 18:09:17 +00:00
for (const entry of entries) {
2025-05-31 18:41:00 +00:00
if (matches.length >= 200) break
2025-05-26 18:09:17 +00:00
2025-05-31 18:41:00 +00:00
const fullPath = path.join(dir, entry.name)
2025-05-26 18:09:17 +00:00
if (entry.isDirectory()) {
// Skip hidden directories
2025-05-31 18:41:00 +00:00
if (entry.name.startsWith(".")) continue
await walkDir(fullPath)
2025-05-26 18:09:17 +00:00
} else if (entry.isFile()) {
// Skip hidden files
2025-05-31 18:41:00 +00:00
if (entry.name.startsWith(".")) continue
2025-05-26 18:09:17 +00:00
if (includePattern && !includePattern.test(fullPath)) {
2025-05-31 18:41:00 +00:00
continue
2025-05-26 18:09:17 +00:00
}
try {
2025-05-31 18:41:00 +00:00
const content = await fs.readFile(fullPath, "utf-8")
const lines = content.split("\n")
2025-05-26 18:09:17 +00:00
for (let i = 0; i < lines.length; i++) {
if (regex.test(lines[i])) {
2025-05-31 18:41:00 +00:00
const stats = await fs.stat(fullPath)
2025-05-26 18:09:17 +00:00
matches.push({
path: fullPath,
modTime: stats.mtime.getTime(),
lineNum: i + 1,
lineText: lines[i],
2025-05-31 18:41:00 +00:00
})
break // Only first match per file
2025-05-26 18:09:17 +00:00
}
}
} catch {
// Skip files we can't read
2025-05-31 18:41:00 +00:00
continue
2025-05-26 18:09:17 +00:00
}
}
}
} catch {
// Skip directories we can't read
2025-05-31 18:41:00 +00:00
return
2025-05-26 18:09:17 +00:00
}
}
2025-05-31 18:41:00 +00:00
await walkDir(rootPath)
return matches
2025-05-26 18:09:17 +00:00
}
async function searchFiles(
pattern: string,
rootPath: string,
include?: string,
limit: number = 100,
): Promise<{ matches: GrepMatch[]; truncated: boolean }> {
2025-05-31 18:41:00 +00:00
let matches: GrepMatch[]
2025-05-26 18:09:17 +00:00
try {
2025-05-31 18:41:00 +00:00
matches = await searchWithRipgrep(pattern, rootPath, include)
2025-05-26 18:09:17 +00:00
} catch {
2025-05-31 18:41:00 +00:00
matches = await searchFilesWithRegex(pattern, rootPath, include)
2025-05-26 18:09:17 +00:00
}
// Sort by modification time (newest first)
2025-05-31 18:41:00 +00:00
matches.sort((a, b) => b.modTime - a.modTime)
2025-05-26 18:09:17 +00:00
2025-05-31 18:41:00 +00:00
const truncated = matches.length > limit
2025-05-26 18:09:17 +00:00
if (truncated) {
2025-05-31 18:41:00 +00:00
matches = matches.slice(0, limit)
2025-05-26 18:09:17 +00:00
}
2025-05-31 18:41:00 +00:00
return { matches, truncated }
2025-05-26 18:09:17 +00:00
}
2025-05-31 21:12:16 +00:00
export const GrepTool = Tool.define({
id: "opencode.grep",
2025-05-26 18:09:17 +00:00
description: DESCRIPTION,
parameters: z.object({
pattern: z
.string()
.describe("The regex pattern to search for in file contents"),
path: z
.string()
.describe(
"The directory to search in. Defaults to the current working directory.",
)
.optional(),
include: z
.string()
.describe(
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
)
.optional(),
literalText: z
2025-05-26 18:09:17 +00:00
.boolean()
.describe(
"If true, the pattern will be treated as literal text with special regex characters escaped. Default is false.",
)
.optional(),
}),
async execute(params) {
if (!params.pattern) {
2025-05-31 18:41:00 +00:00
throw new Error("pattern is required")
2025-05-26 18:09:17 +00:00
}
2025-05-31 18:41:00 +00:00
const app = await App.use()
const searchPath = params.path || app.root
2025-05-26 18:09:17 +00:00
// If literalText is true, escape the pattern
const searchPattern = params.literalText
2025-05-26 18:09:17 +00:00
? escapeRegexPattern(params.pattern)
2025-05-31 18:41:00 +00:00
: params.pattern
2025-05-26 18:09:17 +00:00
const { matches, truncated } = await searchFiles(
searchPattern,
searchPath,
params.include,
100,
2025-05-31 18:41:00 +00:00
)
2025-05-26 18:09:17 +00:00
if (matches.length === 0) {
return {
metadata: { matches: 0, truncated },
2025-05-31 18:41:00 +00:00
output: "No files found",
}
}
2025-05-31 18:41:00 +00:00
const lines = [`Found ${matches.length} matches`]
2025-05-31 18:41:00 +00:00
let currentFile = ""
for (const match of matches) {
if (currentFile !== match.path) {
if (currentFile !== "") {
2025-05-31 18:41:00 +00:00
lines.push("")
2025-05-26 18:09:17 +00:00
}
2025-05-31 18:41:00 +00:00
currentFile = match.path
lines.push(`${match.path}:`)
2025-05-26 18:09:17 +00:00
}
if (match.lineNum > 0) {
2025-05-31 18:41:00 +00:00
lines.push(` Line ${match.lineNum}: ${match.lineText}`)
} else {
2025-05-31 18:41:00 +00:00
lines.push(` ${match.path}`)
2025-05-26 18:09:17 +00:00
}
}
2025-05-26 18:09:17 +00:00
if (truncated) {
2025-05-31 18:41:00 +00:00
lines.push("")
lines.push(
"(Results are truncated. Consider using a more specific path or pattern.)",
2025-05-31 18:41:00 +00:00
)
2025-05-26 18:09:17 +00:00
}
return {
metadata: {
matches: matches.length,
truncated,
},
output: lines.join("\n"),
2025-05-31 18:41:00 +00:00
}
2025-05-26 18:09:17 +00:00
},
2025-05-31 18:41:00 +00:00
})