grep.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. import z from "zod"
  2. import { Tool } from "./tool"
  3. import { Ripgrep } from "../file/ripgrep"
  4. import DESCRIPTION from "./grep.txt"
  5. import { Instance } from "../project/instance"
  6. import path from "path"
  7. import { assertExternalDirectory } from "./external-directory"
  8. const MAX_LINE_LENGTH = 2000
  9. export const GrepTool = Tool.define("grep", {
  10. description: DESCRIPTION,
  11. parameters: z.object({
  12. pattern: z.string().describe("The regex pattern to search for in file contents"),
  13. path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
  14. include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
  15. }),
  16. async execute(params, ctx) {
  17. if (!params.pattern) {
  18. throw new Error("pattern is required")
  19. }
  20. await ctx.ask({
  21. permission: "grep",
  22. patterns: [params.pattern],
  23. always: ["*"],
  24. metadata: {
  25. pattern: params.pattern,
  26. path: params.path,
  27. include: params.include,
  28. },
  29. })
  30. let searchPath = params.path ?? Instance.directory
  31. searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
  32. await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
  33. const rgPath = await Ripgrep.filepath()
  34. const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
  35. if (params.include) {
  36. args.push("--glob", params.include)
  37. }
  38. args.push(searchPath)
  39. const proc = Bun.spawn([rgPath, ...args], {
  40. stdout: "pipe",
  41. stderr: "pipe",
  42. signal: ctx.abort,
  43. })
  44. const output = await new Response(proc.stdout).text()
  45. const errorOutput = await new Response(proc.stderr).text()
  46. const exitCode = await proc.exited
  47. // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
  48. // With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
  49. // Only fail if exit code is 2 AND no output was produced
  50. if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
  51. return {
  52. title: params.pattern,
  53. metadata: { matches: 0, truncated: false },
  54. output: "No files found",
  55. }
  56. }
  57. if (exitCode !== 0 && exitCode !== 2) {
  58. throw new Error(`ripgrep failed: ${errorOutput}`)
  59. }
  60. const hasErrors = exitCode === 2
  61. // Handle both Unix (\n) and Windows (\r\n) line endings
  62. const lines = output.trim().split(/\r?\n/)
  63. const matches = []
  64. for (const line of lines) {
  65. if (!line) continue
  66. const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
  67. if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
  68. const lineNum = parseInt(lineNumStr, 10)
  69. const lineText = lineTextParts.join("|")
  70. const file = Bun.file(filePath)
  71. const stats = await file.stat().catch(() => null)
  72. if (!stats) continue
  73. matches.push({
  74. path: filePath,
  75. modTime: stats.mtime.getTime(),
  76. lineNum,
  77. lineText,
  78. })
  79. }
  80. matches.sort((a, b) => b.modTime - a.modTime)
  81. const limit = 100
  82. const truncated = matches.length > limit
  83. const finalMatches = truncated ? matches.slice(0, limit) : matches
  84. if (finalMatches.length === 0) {
  85. return {
  86. title: params.pattern,
  87. metadata: { matches: 0, truncated: false },
  88. output: "No files found",
  89. }
  90. }
  91. const totalMatches = matches.length
  92. const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
  93. let currentFile = ""
  94. for (const match of finalMatches) {
  95. if (currentFile !== match.path) {
  96. if (currentFile !== "") {
  97. outputLines.push("")
  98. }
  99. currentFile = match.path
  100. outputLines.push(`${match.path}:`)
  101. }
  102. const truncatedLineText =
  103. match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
  104. outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
  105. }
  106. if (truncated) {
  107. outputLines.push("")
  108. outputLines.push(
  109. `(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
  110. )
  111. }
  112. if (hasErrors) {
  113. outputLines.push("")
  114. outputLines.push("(Some paths were inaccessible and skipped)")
  115. }
  116. return {
  117. title: params.pattern,
  118. metadata: {
  119. matches: totalMatches,
  120. truncated,
  121. },
  122. output: outputLines.join("\n"),
  123. }
  124. },
  125. })