152 lines
5.4 KiB
TypeScript
152 lines
5.4 KiB
TypeScript
import path from "path"
|
|
import { Schema } from "effect"
|
|
import { Effect, Option } from "effect"
|
|
import { InstanceState } from "@/effect/instance-state"
|
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|
import { Ripgrep } from "../file/ripgrep"
|
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
|
import DESCRIPTION from "./grep.txt"
|
|
import * as Tool from "./tool"
|
|
|
|
const MAX_LINE_LENGTH = 2000
|
|
|
|
export const Parameters = Schema.Struct({
|
|
pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }),
|
|
path: Schema.optional(Schema.String).annotate({
|
|
description: "The directory to search in. Defaults to the current working directory.",
|
|
}),
|
|
include: Schema.optional(Schema.String).annotate({
|
|
description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
|
|
}),
|
|
})
|
|
|
|
export const GrepTool = Tool.define(
|
|
"grep",
|
|
Effect.gen(function* () {
|
|
const fs = yield* AppFileSystem.Service
|
|
const rg = yield* Ripgrep.Service
|
|
|
|
return {
|
|
description: DESCRIPTION,
|
|
parameters: Parameters,
|
|
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
|
|
Effect.gen(function* () {
|
|
const empty = {
|
|
title: params.pattern,
|
|
metadata: { matches: 0, truncated: false },
|
|
output: "No files found",
|
|
}
|
|
if (!params.pattern) {
|
|
throw new Error("pattern is required")
|
|
}
|
|
|
|
yield* ctx.ask({
|
|
permission: "grep",
|
|
patterns: [params.pattern],
|
|
always: ["*"],
|
|
metadata: {
|
|
pattern: params.pattern,
|
|
path: params.path,
|
|
include: params.include,
|
|
},
|
|
})
|
|
|
|
const ins = yield* InstanceState.context
|
|
const search = AppFileSystem.resolve(
|
|
path.isAbsolute(params.path ?? ins.directory)
|
|
? (params.path ?? ins.directory)
|
|
: path.join(ins.directory, params.path ?? "."),
|
|
)
|
|
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
|
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
|
|
yield* assertExternalDirectoryEffect(ctx, search, {
|
|
kind: info?.type === "Directory" ? "directory" : "file",
|
|
})
|
|
|
|
const result = yield* rg.search({
|
|
cwd,
|
|
pattern: params.pattern,
|
|
glob: params.include ? [params.include] : undefined,
|
|
file,
|
|
signal: ctx.abort,
|
|
})
|
|
if (result.items.length === 0) return empty
|
|
|
|
const rows = result.items.map((item) => ({
|
|
path: AppFileSystem.resolve(
|
|
path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text),
|
|
),
|
|
line: item.line_number,
|
|
text: item.lines.text,
|
|
}))
|
|
const times = new Map(
|
|
(yield* Effect.forEach(
|
|
[...new Set(rows.map((row) => row.path))],
|
|
Effect.fnUntraced(function* (file) {
|
|
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
if (!info || info.type === "Directory") return undefined
|
|
return [
|
|
file,
|
|
info.mtime.pipe(
|
|
Option.map((time) => time.getTime()),
|
|
Option.getOrElse(() => 0),
|
|
) ?? 0,
|
|
] as const
|
|
}),
|
|
{ concurrency: 16 },
|
|
)).filter((entry): entry is readonly [string, number] => Boolean(entry)),
|
|
)
|
|
const matches = rows.flatMap((row) => {
|
|
const mtime = times.get(row.path)
|
|
if (mtime === undefined) return []
|
|
return [{ ...row, mtime }]
|
|
})
|
|
|
|
matches.sort((a, b) => b.mtime - a.mtime)
|
|
|
|
const limit = 100
|
|
const truncated = matches.length > limit
|
|
const final = truncated ? matches.slice(0, limit) : matches
|
|
if (final.length === 0) return empty
|
|
|
|
const total = matches.length
|
|
const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
|
|
|
let current = ""
|
|
for (const match of final) {
|
|
if (current !== match.path) {
|
|
if (current !== "") output.push("")
|
|
current = match.path
|
|
output.push(`${match.path}:`)
|
|
}
|
|
const text =
|
|
match.text.length > MAX_LINE_LENGTH ? match.text.substring(0, MAX_LINE_LENGTH) + "..." : match.text
|
|
output.push(` Line ${match.line}: ${text}`)
|
|
}
|
|
|
|
if (truncated) {
|
|
output.push("")
|
|
output.push(
|
|
`(Results truncated: showing ${limit} of ${total} matches (${total - limit} hidden). Consider using a more specific path or pattern.)`,
|
|
)
|
|
}
|
|
|
|
if (result.partial) {
|
|
output.push("")
|
|
output.push("(Some paths were inaccessible and skipped)")
|
|
}
|
|
|
|
return {
|
|
title: params.pattern,
|
|
metadata: {
|
|
matches: total,
|
|
truncated,
|
|
},
|
|
output: output.join("\n"),
|
|
}
|
|
}).pipe(Effect.orDie),
|
|
}
|
|
}),
|
|
)
|