summaryrefslogtreecommitdiffhomepage
path: root/js/src/tool/grep.ts
blob: 0c5848074d2cd11abfffb4a1c331a20969ba48f4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
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";

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
- Use literal_text=true when searching for exact text containing special characters like dots, parentheses, etc.`;

interface GrepMatch {
  path: string;
  modTime: number;
  lineNum: number;
  lineText: string;
}

function escapeRegexPattern(pattern: string): string {
  const specialChars = [
    "\\",
    ".",
    "+",
    "*",
    "?",
    "(",
    ")",
    "[",
    "]",
    "{",
    "}",
    "^",
    "$",
    "|",
  ];
  let escaped = pattern;

  for (const char of specialChars) {
    escaped = escaped.replaceAll(char, "\\" + char);
  }

  return escaped;
}

function globToRegex(glob: string): string {
  let regexPattern = glob.replaceAll(".", "\\.");
  regexPattern = regexPattern.replaceAll("*", ".*");
  regexPattern = regexPattern.replaceAll("?", ".");

  // Handle {a,b,c} patterns
  regexPattern = regexPattern.replace(/\{([^}]+)\}/g, (_, inner) => {
    return "(" + inner.replace(/,/g, "|") + ")";
  });

  return regexPattern;
}

async function searchWithRipgrep(
  pattern: string,
  searchPath: string,
  include?: string,
): Promise<GrepMatch[]> {
  return new Promise((resolve, reject) => {
    const args = ["-n", pattern];
    if (include) {
      args.push("--glob", include);
    }
    args.push(searchPath);

    const rg = spawn("rg", args);
    let output = "";
    let errorOutput = "";

    rg.stdout.on("data", (data) => {
      output += data.toString();
    });

    rg.stderr.on("data", (data) => {
      errorOutput += data.toString();
    });

    rg.on("close", async (code) => {
      if (code === 1) {
        // No matches found
        resolve([]);
        return;
      }

      if (code !== 0) {
        reject(new Error(`ripgrep failed: ${errorOutput}`));
        return;
      }

      const lines = output.trim().split("\n");
      const matches: GrepMatch[] = [];

      for (const line of lines) {
        if (!line) continue;

        // Parse ripgrep output format: file:line:content
        const parts = line.split(":", 3);
        if (parts.length < 3) continue;

        const filePath = parts[0];
        const lineNum = parseInt(parts[1], 10);
        const lineText = parts[2];

        try {
          const stats = await fs.stat(filePath);
          matches.push({
            path: filePath,
            modTime: stats.mtime.getTime(),
            lineNum,
            lineText,
          });
        } catch {
          // Skip files we can't access
          continue;
        }
      }

      resolve(matches);
    });

    rg.on("error", (err) => {
      reject(err);
    });
  });
}

async function searchFilesWithRegex(
  pattern: string,
  rootPath: string,
  include?: string,
): Promise<GrepMatch[]> {
  const matches: GrepMatch[] = [];
  const regex = new RegExp(pattern);

  let includePattern: RegExp | undefined;
  if (include) {
    const regexPattern = globToRegex(include);
    includePattern = new RegExp(regexPattern);
  }

  async function walkDir(dir: string) {
    if (matches.length >= 200) return;

    try {
      const entries = await fs.readdir(dir, { withFileTypes: true });

      for (const entry of entries) {
        if (matches.length >= 200) break;

        const fullPath = path.join(dir, entry.name);

        if (entry.isDirectory()) {
          // Skip hidden directories
          if (entry.name.startsWith(".")) continue;
          await walkDir(fullPath);
        } else if (entry.isFile()) {
          // Skip hidden files
          if (entry.name.startsWith(".")) continue;

          if (includePattern && !includePattern.test(fullPath)) {
            continue;
          }

          try {
            const content = await fs.readFile(fullPath, "utf-8");
            const lines = content.split("\n");

            for (let i = 0; i < lines.length; i++) {
              if (regex.test(lines[i])) {
                const stats = await fs.stat(fullPath);
                matches.push({
                  path: fullPath,
                  modTime: stats.mtime.getTime(),
                  lineNum: i + 1,
                  lineText: lines[i],
                });
                break; // Only first match per file
              }
            }
          } catch {
            // Skip files we can't read
            continue;
          }
        }
      }
    } catch {
      // Skip directories we can't read
      return;
    }
  }

  await walkDir(rootPath);
  return matches;
}

async function searchFiles(
  pattern: string,
  rootPath: string,
  include?: string,
  limit: number = 100,
): Promise<{ matches: GrepMatch[]; truncated: boolean }> {
  let matches: GrepMatch[];

  try {
    matches = await searchWithRipgrep(pattern, rootPath, include);
  } catch {
    matches = await searchFilesWithRegex(pattern, rootPath, include);
  }

  // Sort by modification time (newest first)
  matches.sort((a, b) => b.modTime - a.modTime);

  const truncated = matches.length > limit;
  if (truncated) {
    matches = matches.slice(0, limit);
  }

  return { matches, truncated };
}

export const grep = Tool.define({
  name: "grep",
  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
      .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) {
      throw new Error("pattern is required");
    }

    const app = await App.use();
    const searchPath = params.path || app.root;

    // If literalText is true, escape the pattern
    const searchPattern = params.literalText
      ? escapeRegexPattern(params.pattern)
      : params.pattern;

    const { matches, truncated } = await searchFiles(
      searchPattern,
      searchPath,
      params.include,
      100,
    );

    if (matches.length === 0) {
      return {
        metadata: { matches: 0, truncated },
        output: "No files found"
      };
    }

    const lines = [`Found ${matches.length} matches`];

    let currentFile = "";
    for (const match of matches) {
      if (currentFile !== match.path) {
        if (currentFile !== "") {
          lines.push("");
        }
        currentFile = match.path;
        lines.push(`${match.path}:`);
      }
      if (match.lineNum > 0) {
        lines.push(`  Line ${match.lineNum}: ${match.lineText}`);
      } else {
        lines.push(`  ${match.path}`);
      }
    }

    if (truncated) {
      lines.push("");
      lines.push(
        "(Results are truncated. Consider using a more specific path or pattern.)",
      );
    }

    return {
      metadata: {
        matches: matches.length,
        truncated,
      },
      output: lines.join("\n"),
    };
  },
});