grep.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. import { z } from "zod"
  2. import { Tool } from "./tool"
  3. import { App } from "../app/app"
  4. import { spawn } from "child_process"
  5. import { promises as fs } from "fs"
  6. import path from "path"
  7. const DESCRIPTION = `Fast content search tool that finds files containing specific text or patterns, returning matching file paths sorted by modification time (newest first).
  8. WHEN TO USE THIS TOOL:
  9. - Use when you need to find files containing specific text or patterns
  10. - Great for searching code bases for function names, variable declarations, or error messages
  11. - Useful for finding all files that use a particular API or pattern
  12. HOW TO USE:
  13. - Provide a regex pattern to search for within file contents
  14. - Set literal_text=true if you want to search for the exact text with special characters (recommended for non-regex users)
  15. - Optionally specify a starting directory (defaults to current working directory)
  16. - Optionally provide an include pattern to filter which files to search
  17. - Results are sorted with most recently modified files first
  18. REGEX PATTERN SYNTAX (when literal_text=false):
  19. - Supports standard regular expression syntax
  20. - 'function' searches for the literal text "function"
  21. - 'log\\..*Error' finds text starting with "log." and ending with "Error"
  22. - 'import\\s+.*\\s+from' finds import statements in JavaScript/TypeScript
  23. COMMON INCLUDE PATTERN EXAMPLES:
  24. - '*.js' - Only search JavaScript files
  25. - '*.{ts,tsx}' - Only search TypeScript files
  26. - '*.go' - Only search Go files
  27. LIMITATIONS:
  28. - Results are limited to 100 files (newest first)
  29. - Performance depends on the number of files being searched
  30. - Very large binary files may be skipped
  31. - Hidden files (starting with '.') are skipped
  32. TIPS:
  33. - For faster, more targeted searches, first use Glob to find relevant files, then use Grep
  34. - When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
  35. - Always check if results are truncated and refine your search pattern if needed
  36. - Use literal_text=true when searching for exact text containing special characters like dots, parentheses, etc.`
  37. interface GrepMatch {
  38. path: string
  39. modTime: number
  40. lineNum: number
  41. lineText: string
  42. }
  43. function escapeRegexPattern(pattern: string): string {
  44. const specialChars = [
  45. "\\",
  46. ".",
  47. "+",
  48. "*",
  49. "?",
  50. "(",
  51. ")",
  52. "[",
  53. "]",
  54. "{",
  55. "}",
  56. "^",
  57. "$",
  58. "|",
  59. ]
  60. let escaped = pattern
  61. for (const char of specialChars) {
  62. escaped = escaped.replaceAll(char, "\\" + char)
  63. }
  64. return escaped
  65. }
  66. function globToRegex(glob: string): string {
  67. let regexPattern = glob.replaceAll(".", "\\.")
  68. regexPattern = regexPattern.replaceAll("*", ".*")
  69. regexPattern = regexPattern.replaceAll("?", ".")
  70. // Handle {a,b,c} patterns
  71. regexPattern = regexPattern.replace(/\{([^}]+)\}/g, (_, inner) => {
  72. return "(" + inner.replace(/,/g, "|") + ")"
  73. })
  74. return regexPattern
  75. }
  76. async function searchWithRipgrep(
  77. pattern: string,
  78. searchPath: string,
  79. include?: string,
  80. ): Promise<GrepMatch[]> {
  81. return new Promise((resolve, reject) => {
  82. const args = ["-n", pattern]
  83. if (include) {
  84. args.push("--glob", include)
  85. }
  86. args.push(searchPath)
  87. const rg = spawn("rg", args)
  88. let output = ""
  89. let errorOutput = ""
  90. rg.stdout.on("data", (data) => {
  91. output += data.toString()
  92. })
  93. rg.stderr.on("data", (data) => {
  94. errorOutput += data.toString()
  95. })
  96. rg.on("close", async (code) => {
  97. if (code === 1) {
  98. // No matches found
  99. resolve([])
  100. return
  101. }
  102. if (code !== 0) {
  103. reject(new Error(`ripgrep failed: ${errorOutput}`))
  104. return
  105. }
  106. const lines = output.trim().split("\n")
  107. const matches: GrepMatch[] = []
  108. for (const line of lines) {
  109. if (!line) continue
  110. // Parse ripgrep output format: file:line:content
  111. const parts = line.split(":", 3)
  112. if (parts.length < 3) continue
  113. const filePath = parts[0]
  114. const lineNum = parseInt(parts[1], 10)
  115. const lineText = parts[2]
  116. try {
  117. const stats = await fs.stat(filePath)
  118. matches.push({
  119. path: filePath,
  120. modTime: stats.mtime.getTime(),
  121. lineNum,
  122. lineText,
  123. })
  124. } catch {
  125. // Skip files we can't access
  126. continue
  127. }
  128. }
  129. resolve(matches)
  130. })
  131. rg.on("error", (err) => {
  132. reject(err)
  133. })
  134. })
  135. }
  136. async function searchFilesWithRegex(
  137. pattern: string,
  138. rootPath: string,
  139. include?: string,
  140. ): Promise<GrepMatch[]> {
  141. const matches: GrepMatch[] = []
  142. const regex = new RegExp(pattern)
  143. let includePattern: RegExp | undefined
  144. if (include) {
  145. const regexPattern = globToRegex(include)
  146. includePattern = new RegExp(regexPattern)
  147. }
  148. async function walkDir(dir: string) {
  149. if (matches.length >= 200) return
  150. try {
  151. const entries = await fs.readdir(dir, { withFileTypes: true })
  152. for (const entry of entries) {
  153. if (matches.length >= 200) break
  154. const fullPath = path.join(dir, entry.name)
  155. if (entry.isDirectory()) {
  156. // Skip hidden directories
  157. if (entry.name.startsWith(".")) continue
  158. await walkDir(fullPath)
  159. } else if (entry.isFile()) {
  160. // Skip hidden files
  161. if (entry.name.startsWith(".")) continue
  162. if (includePattern && !includePattern.test(fullPath)) {
  163. continue
  164. }
  165. try {
  166. const content = await fs.readFile(fullPath, "utf-8")
  167. const lines = content.split("\n")
  168. for (let i = 0; i < lines.length; i++) {
  169. if (regex.test(lines[i])) {
  170. const stats = await fs.stat(fullPath)
  171. matches.push({
  172. path: fullPath,
  173. modTime: stats.mtime.getTime(),
  174. lineNum: i + 1,
  175. lineText: lines[i],
  176. })
  177. break // Only first match per file
  178. }
  179. }
  180. } catch {
  181. // Skip files we can't read
  182. continue
  183. }
  184. }
  185. }
  186. } catch {
  187. // Skip directories we can't read
  188. return
  189. }
  190. }
  191. await walkDir(rootPath)
  192. return matches
  193. }
  194. async function searchFiles(
  195. pattern: string,
  196. rootPath: string,
  197. include?: string,
  198. limit: number = 100,
  199. ): Promise<{ matches: GrepMatch[]; truncated: boolean }> {
  200. let matches: GrepMatch[]
  201. try {
  202. matches = await searchWithRipgrep(pattern, rootPath, include)
  203. } catch {
  204. matches = await searchFilesWithRegex(pattern, rootPath, include)
  205. }
  206. // Sort by modification time (newest first)
  207. matches.sort((a, b) => b.modTime - a.modTime)
  208. const truncated = matches.length > limit
  209. if (truncated) {
  210. matches = matches.slice(0, limit)
  211. }
  212. return { matches, truncated }
  213. }
  214. export const GrepTool = Tool.define({
  215. id: "opencode.grep",
  216. description: DESCRIPTION,
  217. parameters: z.object({
  218. pattern: z
  219. .string()
  220. .describe("The regex pattern to search for in file contents"),
  221. path: z
  222. .string()
  223. .describe(
  224. "The directory to search in. Defaults to the current working directory.",
  225. )
  226. .optional(),
  227. include: z
  228. .string()
  229. .describe(
  230. 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
  231. )
  232. .optional(),
  233. literalText: z
  234. .boolean()
  235. .describe(
  236. "If true, the pattern will be treated as literal text with special regex characters escaped. Default is false.",
  237. )
  238. .optional(),
  239. }),
  240. async execute(params) {
  241. if (!params.pattern) {
  242. throw new Error("pattern is required")
  243. }
  244. const app = App.info()
  245. const searchPath = params.path || app.path.cwd
  246. // If literalText is true, escape the pattern
  247. const searchPattern = params.literalText
  248. ? escapeRegexPattern(params.pattern)
  249. : params.pattern
  250. const { matches, truncated } = await searchFiles(
  251. searchPattern,
  252. searchPath,
  253. params.include,
  254. 100,
  255. )
  256. if (matches.length === 0) {
  257. return {
  258. metadata: { matches: 0, truncated },
  259. output: "No files found",
  260. }
  261. }
  262. const lines = [`Found ${matches.length} matches`]
  263. let currentFile = ""
  264. for (const match of matches) {
  265. if (currentFile !== match.path) {
  266. if (currentFile !== "") {
  267. lines.push("")
  268. }
  269. currentFile = match.path
  270. lines.push(`${match.path}:`)
  271. }
  272. if (match.lineNum > 0) {
  273. lines.push(` Line ${match.lineNum}: ${match.lineText}`)
  274. } else {
  275. lines.push(` ${match.path}`)
  276. }
  277. }
  278. if (truncated) {
  279. lines.push("")
  280. lines.push(
  281. "(Results are truncated. Consider using a more specific path or pattern.)",
  282. )
  283. }
  284. return {
  285. metadata: {
  286. matches: matches.length,
  287. truncated,
  288. },
  289. output: lines.join("\n"),
  290. }
  291. },
  292. })