178 lines
6.2 KiB
TypeScript
178 lines
6.2 KiB
TypeScript
import z from "zod"
|
|
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 { 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 { assertExternalDirectoryEffect } from "./external-directory"
|
|
|
|
const MAX_LINE_LENGTH = 2000
|
|
|
|
export const GrepTool = Tool.defineEffect(
|
|
"grep",
|
|
Effect.gen(function* () {
|
|
const spawner = yield* ChildProcessSpawner
|
|
|
|
return {
|
|
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),
|
|
}
|
|
}),
|
|
)
|