From d72ddd71faf144864c985cd6372eeea7f2d4ba6f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 10 Apr 2026 19:20:00 -0400 Subject: [PATCH] refactor(tool): convert grep tool to Tool.defineEffect (#21937) --- packages/opencode/src/tool/grep.ts | 305 ++++++++++++----------- packages/opencode/src/tool/registry.ts | 3 +- packages/opencode/test/tool/grep.test.ts | 14 +- 3 files changed, 176 insertions(+), 146 deletions(-) diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 82e7ac1667..8f53c2e21a 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -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), } - }, -}) + }), +) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index bbc154371c..84dd7b79aa 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -112,6 +112,7 @@ export namespace ToolRegistry { const globtool = yield* GlobTool const writetool = yield* WriteTool const edit = yield* EditTool + const greptool = yield* GrepTool const state = yield* InstanceState.make( Effect.fn("ToolRegistry.state")(function* (ctx) { @@ -173,7 +174,7 @@ export namespace ToolRegistry { bash: Tool.init(bash), read: Tool.init(read), glob: Tool.init(globtool), - grep: Tool.init(GrepTool), + grep: Tool.init(greptool), edit: Tool.init(edit), write: Tool.init(writetool), task: Tool.init(task), diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index e03b1752ec..a0cfb61c40 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -1,9 +1,17 @@ import { describe, expect, test } from "bun:test" import path from "path" +import { Effect, Layer, ManagedRuntime } from "effect" import { GrepTool } from "../../src/tool/grep" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" + +const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer)) + +function initGrep() { + return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init())))) +} const ctx = { sessionID: SessionID.make("ses_test"), @@ -23,7 +31,7 @@ describe("tool.grep", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const grep = await GrepTool.init() + const grep = await initGrep() const result = await grep.execute( { pattern: "export", @@ -47,7 +55,7 @@ describe("tool.grep", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const grep = await GrepTool.init() + const grep = await initGrep() const result = await grep.execute( { pattern: "xyznonexistentpatternxyz123", @@ -72,7 +80,7 @@ describe("tool.grep", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const grep = await GrepTool.init() + const grep = await initGrep() const result = await grep.execute( { pattern: "line",