Skip to content

Commit d72ddd7

Browse files
authored
refactor(tool): convert grep tool to Tool.defineEffect (#21937)
1 parent fb26308 commit d72ddd7

3 files changed

Lines changed: 176 additions & 146 deletions

File tree

packages/opencode/src/tool/grep.ts

Lines changed: 163 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -1,156 +1,177 @@
11
import z from "zod"
2-
import { text } from "node:stream/consumers"
2+
import { Effect } from "effect"
3+
import * as Stream from "effect/Stream"
34
import { Tool } from "./tool"
45
import { Filesystem } from "../util/filesystem"
56
import { Ripgrep } from "../file/ripgrep"
6-
import { Process } from "../util/process"
7+
import { ChildProcess } from "effect/unstable/process"
8+
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
79

810
import DESCRIPTION from "./grep.txt"
911
import { Instance } from "../project/instance"
1012
import path from "path"
11-
import { assertExternalDirectory } from "./external-directory"
13+
import { assertExternalDirectoryEffect } from "./external-directory"
1214

1315
const MAX_LINE_LENGTH = 2000
1416

15-
export const GrepTool = Tool.define("grep", {
16-
description: DESCRIPTION,
17-
parameters: z.object({
18-
pattern: z.string().describe("The regex pattern to search for in file contents"),
19-
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
20-
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
21-
}),
22-
async execute(params, ctx) {
23-
if (!params.pattern) {
24-
throw new Error("pattern is required")
25-
}
26-
27-
await ctx.ask({
28-
permission: "grep",
29-
patterns: [params.pattern],
30-
always: ["*"],
31-
metadata: {
32-
pattern: params.pattern,
33-
path: params.path,
34-
include: params.include,
35-
},
36-
})
37-
38-
let searchPath = params.path ?? Instance.directory
39-
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
40-
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
41-
42-
const rgPath = await Ripgrep.filepath()
43-
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
44-
if (params.include) {
45-
args.push("--glob", params.include)
46-
}
47-
args.push(searchPath)
48-
49-
const proc = Process.spawn([rgPath, ...args], {
50-
stdout: "pipe",
51-
stderr: "pipe",
52-
abort: ctx.abort,
53-
})
54-
55-
if (!proc.stdout || !proc.stderr) {
56-
throw new Error("Process output not available")
57-
}
58-
59-
const output = await text(proc.stdout)
60-
const errorOutput = await text(proc.stderr)
61-
const exitCode = await proc.exited
62-
63-
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
64-
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
65-
// Only fail if exit code is 2 AND no output was produced
66-
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
67-
return {
68-
title: params.pattern,
69-
metadata: { matches: 0, truncated: false },
70-
output: "No files found",
71-
}
72-
}
73-
74-
if (exitCode !== 0 && exitCode !== 2) {
75-
throw new Error(`ripgrep failed: ${errorOutput}`)
76-
}
77-
78-
const hasErrors = exitCode === 2
79-
80-
// Handle both Unix (\n) and Windows (\r\n) line endings
81-
const lines = output.trim().split(/\r?\n/)
82-
const matches = []
83-
84-
for (const line of lines) {
85-
if (!line) continue
86-
87-
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
88-
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
89-
90-
const lineNum = parseInt(lineNumStr, 10)
91-
const lineText = lineTextParts.join("|")
92-
93-
const stats = Filesystem.stat(filePath)
94-
if (!stats) continue
95-
96-
matches.push({
97-
path: filePath,
98-
modTime: stats.mtime.getTime(),
99-
lineNum,
100-
lineText,
101-
})
102-
}
103-
104-
matches.sort((a, b) => b.modTime - a.modTime)
105-
106-
const limit = 100
107-
const truncated = matches.length > limit
108-
const finalMatches = truncated ? matches.slice(0, limit) : matches
109-
110-
if (finalMatches.length === 0) {
111-
return {
112-
title: params.pattern,
113-
metadata: { matches: 0, truncated: false },
114-
output: "No files found",
115-
}
116-
}
117-
118-
const totalMatches = matches.length
119-
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
120-
121-
let currentFile = ""
122-
for (const match of finalMatches) {
123-
if (currentFile !== match.path) {
124-
if (currentFile !== "") {
125-
outputLines.push("")
126-
}
127-
currentFile = match.path
128-
outputLines.push(`${match.path}:`)
129-
}
130-
const truncatedLineText =
131-
match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
132-
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
133-
}
134-
135-
if (truncated) {
136-
outputLines.push("")
137-
outputLines.push(
138-
`(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
139-
)
140-
}
141-
142-
if (hasErrors) {
143-
outputLines.push("")
144-
outputLines.push("(Some paths were inaccessible and skipped)")
145-
}
17+
export const GrepTool = Tool.defineEffect(
18+
"grep",
19+
Effect.gen(function* () {
20+
const spawner = yield* ChildProcessSpawner
14621

14722
return {
148-
title: params.pattern,
149-
metadata: {
150-
matches: totalMatches,
151-
truncated,
152-
},
153-
output: outputLines.join("\n"),
23+
description: DESCRIPTION,
24+
parameters: z.object({
25+
pattern: z.string().describe("The regex pattern to search for in file contents"),
26+
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
27+
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
28+
}),
29+
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
30+
Effect.gen(function* () {
31+
if (!params.pattern) {
32+
throw new Error("pattern is required")
33+
}
34+
35+
yield* Effect.promise(() =>
36+
ctx.ask({
37+
permission: "grep",
38+
patterns: [params.pattern],
39+
always: ["*"],
40+
metadata: {
41+
pattern: params.pattern,
42+
path: params.path,
43+
include: params.include,
44+
},
45+
}),
46+
)
47+
48+
let searchPath = params.path ?? Instance.directory
49+
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
50+
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
51+
52+
const rgPath = yield* Effect.promise(() => Ripgrep.filepath())
53+
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
54+
if (params.include) {
55+
args.push("--glob", params.include)
56+
}
57+
args.push(searchPath)
58+
59+
const result = yield* Effect.scoped(
60+
Effect.gen(function* () {
61+
const handle = yield* spawner.spawn(
62+
ChildProcess.make(rgPath, args, {
63+
stdin: "ignore",
64+
}),
65+
)
66+
67+
const [output, errorOutput] = yield* Effect.all(
68+
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
69+
{ concurrency: 2 },
70+
)
71+
72+
const exitCode = yield* handle.exitCode
73+
74+
return { output, errorOutput, exitCode }
75+
}),
76+
)
77+
78+
const { output, errorOutput, exitCode } = result
79+
80+
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
81+
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
82+
// Only fail if exit code is 2 AND no output was produced
83+
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
84+
return {
85+
title: params.pattern,
86+
metadata: { matches: 0, truncated: false },
87+
output: "No files found",
88+
}
89+
}
90+
91+
if (exitCode !== 0 && exitCode !== 2) {
92+
throw new Error(`ripgrep failed: ${errorOutput}`)
93+
}
94+
95+
const hasErrors = exitCode === 2
96+
97+
// Handle both Unix (\n) and Windows (\r\n) line endings
98+
const lines = output.trim().split(/\r?\n/)
99+
const matches = []
100+
101+
for (const line of lines) {
102+
if (!line) continue
103+
104+
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
105+
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
106+
107+
const lineNum = parseInt(lineNumStr, 10)
108+
const lineText = lineTextParts.join("|")
109+
110+
const stats = Filesystem.stat(filePath)
111+
if (!stats) continue
112+
113+
matches.push({
114+
path: filePath,
115+
modTime: stats.mtime.getTime(),
116+
lineNum,
117+
lineText,
118+
})
119+
}
120+
121+
matches.sort((a, b) => b.modTime - a.modTime)
122+
123+
const limit = 100
124+
const truncated = matches.length > limit
125+
const finalMatches = truncated ? matches.slice(0, limit) : matches
126+
127+
if (finalMatches.length === 0) {
128+
return {
129+
title: params.pattern,
130+
metadata: { matches: 0, truncated: false },
131+
output: "No files found",
132+
}
133+
}
134+
135+
const totalMatches = matches.length
136+
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
137+
138+
let currentFile = ""
139+
for (const match of finalMatches) {
140+
if (currentFile !== match.path) {
141+
if (currentFile !== "") {
142+
outputLines.push("")
143+
}
144+
currentFile = match.path
145+
outputLines.push(`${match.path}:`)
146+
}
147+
const truncatedLineText =
148+
match.lineText.length > MAX_LINE_LENGTH
149+
? match.lineText.substring(0, MAX_LINE_LENGTH) + "..."
150+
: match.lineText
151+
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
152+
}
153+
154+
if (truncated) {
155+
outputLines.push("")
156+
outputLines.push(
157+
`(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
158+
)
159+
}
160+
161+
if (hasErrors) {
162+
outputLines.push("")
163+
outputLines.push("(Some paths were inaccessible and skipped)")
164+
}
165+
166+
return {
167+
title: params.pattern,
168+
metadata: {
169+
matches: totalMatches,
170+
truncated,
171+
},
172+
output: outputLines.join("\n"),
173+
}
174+
}).pipe(Effect.orDie, Effect.runPromise),
154175
}
155-
},
156-
})
176+
}),
177+
)

packages/opencode/src/tool/registry.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export namespace ToolRegistry {
112112
const globtool = yield* GlobTool
113113
const writetool = yield* WriteTool
114114
const edit = yield* EditTool
115+
const greptool = yield* GrepTool
115116

116117
const state = yield* InstanceState.make<State>(
117118
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -173,7 +174,7 @@ export namespace ToolRegistry {
173174
bash: Tool.init(bash),
174175
read: Tool.init(read),
175176
glob: Tool.init(globtool),
176-
grep: Tool.init(GrepTool),
177+
grep: Tool.init(greptool),
177178
edit: Tool.init(edit),
178179
write: Tool.init(writetool),
179180
task: Tool.init(task),

packages/opencode/test/tool/grep.test.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import { describe, expect, test } from "bun:test"
22
import path from "path"
3+
import { Effect, Layer, ManagedRuntime } from "effect"
34
import { GrepTool } from "../../src/tool/grep"
45
import { Instance } from "../../src/project/instance"
56
import { tmpdir } from "../fixture/fixture"
67
import { SessionID, MessageID } from "../../src/session/schema"
8+
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
9+
10+
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer))
11+
12+
function initGrep() {
13+
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))))
14+
}
715

816
const ctx = {
917
sessionID: SessionID.make("ses_test"),
@@ -23,7 +31,7 @@ describe("tool.grep", () => {
2331
await Instance.provide({
2432
directory: projectRoot,
2533
fn: async () => {
26-
const grep = await GrepTool.init()
34+
const grep = await initGrep()
2735
const result = await grep.execute(
2836
{
2937
pattern: "export",
@@ -47,7 +55,7 @@ describe("tool.grep", () => {
4755
await Instance.provide({
4856
directory: tmp.path,
4957
fn: async () => {
50-
const grep = await GrepTool.init()
58+
const grep = await initGrep()
5159
const result = await grep.execute(
5260
{
5361
pattern: "xyznonexistentpatternxyz123",
@@ -72,7 +80,7 @@ describe("tool.grep", () => {
7280
await Instance.provide({
7381
directory: tmp.path,
7482
fn: async () => {
75-
const grep = await GrepTool.init()
83+
const grep = await initGrep()
7684
const result = await grep.execute(
7785
{
7886
pattern: "line",

0 commit comments

Comments
 (0)