|
|
@@ -1,156 +1,177 @@
|
|
|
import z from "zod"
|
|
|
-import { text } from "node:stream/consumers"
|
|
|
+import { Effect } from "effect"
|
|
|
+import * as Stream from "effect/Stream"
|
|
|
import { Tool } from "./tool"
|
|
|
import { Filesystem } from "../util/filesystem"
|
|
|
import { Ripgrep } from "../file/ripgrep"
|
|
|
-import { Process } from "../util/process"
|
|
|
+import { ChildProcess } from "effect/unstable/process"
|
|
|
+import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
|
|
|
|
|
import DESCRIPTION from "./grep.txt"
|
|
|
import { Instance } from "../project/instance"
|
|
|
import path from "path"
|
|
|
-import { assertExternalDirectory } from "./external-directory"
|
|
|
+import { assertExternalDirectoryEffect } from "./external-directory"
|
|
|
|
|
|
const MAX_LINE_LENGTH = 2000
|
|
|
|
|
|
-export const GrepTool = Tool.define("grep", {
|
|
|
- description: DESCRIPTION,
|
|
|
- parameters: z.object({
|
|
|
- pattern: z.string().describe("The regex pattern to search for in file contents"),
|
|
|
- path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
|
|
|
- include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
|
|
|
- }),
|
|
|
- async execute(params, ctx) {
|
|
|
- if (!params.pattern) {
|
|
|
- throw new Error("pattern is required")
|
|
|
- }
|
|
|
-
|
|
|
- await ctx.ask({
|
|
|
- permission: "grep",
|
|
|
- patterns: [params.pattern],
|
|
|
- always: ["*"],
|
|
|
- metadata: {
|
|
|
- pattern: params.pattern,
|
|
|
- path: params.path,
|
|
|
- include: params.include,
|
|
|
- },
|
|
|
- })
|
|
|
-
|
|
|
- let searchPath = params.path ?? Instance.directory
|
|
|
- searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
|
|
|
- await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
|
|
|
-
|
|
|
- const rgPath = await Ripgrep.filepath()
|
|
|
- const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
|
|
|
- if (params.include) {
|
|
|
- args.push("--glob", params.include)
|
|
|
- }
|
|
|
- args.push(searchPath)
|
|
|
-
|
|
|
- const proc = Process.spawn([rgPath, ...args], {
|
|
|
- stdout: "pipe",
|
|
|
- stderr: "pipe",
|
|
|
- abort: ctx.abort,
|
|
|
- })
|
|
|
-
|
|
|
- if (!proc.stdout || !proc.stderr) {
|
|
|
- throw new Error("Process output not available")
|
|
|
- }
|
|
|
-
|
|
|
- const output = await text(proc.stdout)
|
|
|
- const errorOutput = await text(proc.stderr)
|
|
|
- const exitCode = await proc.exited
|
|
|
-
|
|
|
- // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
|
|
|
- // With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
|
|
|
- // Only fail if exit code is 2 AND no output was produced
|
|
|
- if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
|
|
|
- return {
|
|
|
- title: params.pattern,
|
|
|
- metadata: { matches: 0, truncated: false },
|
|
|
- output: "No files found",
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (exitCode !== 0 && exitCode !== 2) {
|
|
|
- throw new Error(`ripgrep failed: ${errorOutput}`)
|
|
|
- }
|
|
|
-
|
|
|
- const hasErrors = exitCode === 2
|
|
|
-
|
|
|
- // Handle both Unix (\n) and Windows (\r\n) line endings
|
|
|
- const lines = output.trim().split(/\r?\n/)
|
|
|
- const matches = []
|
|
|
-
|
|
|
- for (const line of lines) {
|
|
|
- if (!line) continue
|
|
|
-
|
|
|
- const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
|
|
|
- if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
|
|
|
-
|
|
|
- const lineNum = parseInt(lineNumStr, 10)
|
|
|
- const lineText = lineTextParts.join("|")
|
|
|
-
|
|
|
- const stats = Filesystem.stat(filePath)
|
|
|
- if (!stats) continue
|
|
|
-
|
|
|
- matches.push({
|
|
|
- path: filePath,
|
|
|
- modTime: stats.mtime.getTime(),
|
|
|
- lineNum,
|
|
|
- lineText,
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- matches.sort((a, b) => b.modTime - a.modTime)
|
|
|
-
|
|
|
- const limit = 100
|
|
|
- const truncated = matches.length > limit
|
|
|
- const finalMatches = truncated ? matches.slice(0, limit) : matches
|
|
|
-
|
|
|
- if (finalMatches.length === 0) {
|
|
|
- return {
|
|
|
- title: params.pattern,
|
|
|
- metadata: { matches: 0, truncated: false },
|
|
|
- output: "No files found",
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- const totalMatches = matches.length
|
|
|
- const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
|
|
-
|
|
|
- let currentFile = ""
|
|
|
- for (const match of finalMatches) {
|
|
|
- if (currentFile !== match.path) {
|
|
|
- if (currentFile !== "") {
|
|
|
- outputLines.push("")
|
|
|
- }
|
|
|
- currentFile = match.path
|
|
|
- outputLines.push(`${match.path}:`)
|
|
|
- }
|
|
|
- const truncatedLineText =
|
|
|
- match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
|
|
|
- outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
|
|
|
- }
|
|
|
-
|
|
|
- if (truncated) {
|
|
|
- outputLines.push("")
|
|
|
- outputLines.push(
|
|
|
- `(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
|
|
|
- )
|
|
|
- }
|
|
|
-
|
|
|
- if (hasErrors) {
|
|
|
- outputLines.push("")
|
|
|
- outputLines.push("(Some paths were inaccessible and skipped)")
|
|
|
- }
|
|
|
+export const GrepTool = Tool.defineEffect(
|
|
|
+ "grep",
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const spawner = yield* ChildProcessSpawner
|
|
|
|
|
|
return {
|
|
|
- title: params.pattern,
|
|
|
- metadata: {
|
|
|
- matches: totalMatches,
|
|
|
- truncated,
|
|
|
- },
|
|
|
- output: outputLines.join("\n"),
|
|
|
+ description: DESCRIPTION,
|
|
|
+ parameters: z.object({
|
|
|
+ pattern: z.string().describe("The regex pattern to search for in file contents"),
|
|
|
+ path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
|
|
|
+ include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
|
|
|
+ }),
|
|
|
+ execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ if (!params.pattern) {
|
|
|
+ throw new Error("pattern is required")
|
|
|
+ }
|
|
|
+
|
|
|
+ yield* Effect.promise(() =>
|
|
|
+ ctx.ask({
|
|
|
+ permission: "grep",
|
|
|
+ patterns: [params.pattern],
|
|
|
+ always: ["*"],
|
|
|
+ metadata: {
|
|
|
+ pattern: params.pattern,
|
|
|
+ path: params.path,
|
|
|
+ include: params.include,
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ let searchPath = params.path ?? Instance.directory
|
|
|
+ searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
|
|
|
+ yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
|
|
|
+
|
|
|
+ const rgPath = yield* Effect.promise(() => Ripgrep.filepath())
|
|
|
+ const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
|
|
|
+ if (params.include) {
|
|
|
+ args.push("--glob", params.include)
|
|
|
+ }
|
|
|
+ args.push(searchPath)
|
|
|
+
|
|
|
+ const result = yield* Effect.scoped(
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const handle = yield* spawner.spawn(
|
|
|
+ ChildProcess.make(rgPath, args, {
|
|
|
+ stdin: "ignore",
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ const [output, errorOutput] = yield* Effect.all(
|
|
|
+ [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
|
|
+ { concurrency: 2 },
|
|
|
+ )
|
|
|
+
|
|
|
+ const exitCode = yield* handle.exitCode
|
|
|
+
|
|
|
+ return { output, errorOutput, exitCode }
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ const { output, errorOutput, exitCode } = result
|
|
|
+
|
|
|
+ // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
|
|
|
+ // With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
|
|
|
+ // Only fail if exit code is 2 AND no output was produced
|
|
|
+ if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
|
|
|
+ return {
|
|
|
+ title: params.pattern,
|
|
|
+ metadata: { matches: 0, truncated: false },
|
|
|
+ output: "No files found",
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (exitCode !== 0 && exitCode !== 2) {
|
|
|
+ throw new Error(`ripgrep failed: ${errorOutput}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ const hasErrors = exitCode === 2
|
|
|
+
|
|
|
+ // Handle both Unix (\n) and Windows (\r\n) line endings
|
|
|
+ const lines = output.trim().split(/\r?\n/)
|
|
|
+ const matches = []
|
|
|
+
|
|
|
+ for (const line of lines) {
|
|
|
+ if (!line) continue
|
|
|
+
|
|
|
+ const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
|
|
|
+ if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
|
|
|
+
|
|
|
+ const lineNum = parseInt(lineNumStr, 10)
|
|
|
+ const lineText = lineTextParts.join("|")
|
|
|
+
|
|
|
+ const stats = Filesystem.stat(filePath)
|
|
|
+ if (!stats) continue
|
|
|
+
|
|
|
+ matches.push({
|
|
|
+ path: filePath,
|
|
|
+ modTime: stats.mtime.getTime(),
|
|
|
+ lineNum,
|
|
|
+ lineText,
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ matches.sort((a, b) => b.modTime - a.modTime)
|
|
|
+
|
|
|
+ const limit = 100
|
|
|
+ const truncated = matches.length > limit
|
|
|
+ const finalMatches = truncated ? matches.slice(0, limit) : matches
|
|
|
+
|
|
|
+ if (finalMatches.length === 0) {
|
|
|
+ return {
|
|
|
+ title: params.pattern,
|
|
|
+ metadata: { matches: 0, truncated: false },
|
|
|
+ output: "No files found",
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const totalMatches = matches.length
|
|
|
+ const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
|
|
+
|
|
|
+ let currentFile = ""
|
|
|
+ for (const match of finalMatches) {
|
|
|
+ if (currentFile !== match.path) {
|
|
|
+ if (currentFile !== "") {
|
|
|
+ outputLines.push("")
|
|
|
+ }
|
|
|
+ currentFile = match.path
|
|
|
+ outputLines.push(`${match.path}:`)
|
|
|
+ }
|
|
|
+ const truncatedLineText =
|
|
|
+ match.lineText.length > MAX_LINE_LENGTH
|
|
|
+ ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..."
|
|
|
+ : match.lineText
|
|
|
+ outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (truncated) {
|
|
|
+ outputLines.push("")
|
|
|
+ outputLines.push(
|
|
|
+ `(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ if (hasErrors) {
|
|
|
+ outputLines.push("")
|
|
|
+ outputLines.push("(Some paths were inaccessible and skipped)")
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ title: params.pattern,
|
|
|
+ metadata: {
|
|
|
+ matches: totalMatches,
|
|
|
+ truncated,
|
|
|
+ },
|
|
|
+ output: outputLines.join("\n"),
|
|
|
+ }
|
|
|
+ }).pipe(Effect.orDie, Effect.runPromise),
|
|
|
}
|
|
|
- },
|
|
|
-})
|
|
|
+ }),
|
|
|
+)
|