Skip to content

Commit 847f1d9

Browse files
authored
convert glob tool to Tool.defineEffect (#21897)
1 parent 59d0868 commit 847f1d9

File tree

6 files changed

+204
-67
lines changed

6 files changed

+204
-67
lines changed

packages/opencode/src/file/ripgrep.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@ import path from "path"
33
import { Global } from "../global"
44
import fs from "fs/promises"
55
import z from "zod"
6+
import { Effect, Layer, ServiceMap } from "effect"
7+
import * as Stream from "effect/Stream"
8+
import { ChildProcess } from "effect/unstable/process"
9+
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
10+
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
11+
import type { PlatformError } from "effect/PlatformError"
612
import { NamedError } from "@opencode-ai/util/error"
713
import { lazy } from "../util/lazy"
814

915
import { Filesystem } from "../util/filesystem"
16+
import { AppFileSystem } from "../filesystem"
1017
import { Process } from "../util/process"
1118
import { which } from "../util/which"
1219
import { text } from "node:stream/consumers"
@@ -274,6 +281,69 @@ export namespace Ripgrep {
274281
input.signal?.throwIfAborted()
275282
}
276283

284+
export interface Interface {
285+
readonly files: (input: {
286+
cwd: string
287+
glob?: string[]
288+
hidden?: boolean
289+
follow?: boolean
290+
maxDepth?: number
291+
}) => Stream.Stream<string, PlatformError>
292+
}
293+
294+
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Ripgrep") {}
295+
296+
export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
297+
Service,
298+
Effect.gen(function* () {
299+
const spawner = yield* ChildProcessSpawner
300+
const afs = yield* AppFileSystem.Service
301+
302+
const files = Effect.fn("Ripgrep.files")(function* (input: {
303+
cwd: string
304+
glob?: string[]
305+
hidden?: boolean
306+
follow?: boolean
307+
maxDepth?: number
308+
}) {
309+
const rgPath = yield* Effect.promise(() => filepath())
310+
const isDir = yield* afs.isDir(input.cwd)
311+
if (!isDir) {
312+
return yield* Effect.die(
313+
Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
314+
code: "ENOENT" as const,
315+
errno: -2,
316+
path: input.cwd,
317+
}),
318+
)
319+
}
320+
321+
const args = [rgPath, "--files", "--glob=!.git/*"]
322+
if (input.follow) args.push("--follow")
323+
if (input.hidden !== false) args.push("--hidden")
324+
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
325+
if (input.glob) {
326+
for (const g of input.glob) {
327+
args.push(`--glob=${g}`)
328+
}
329+
}
330+
331+
return spawner.streamLines(
332+
ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }),
333+
).pipe(Stream.filter((line: string) => line.length > 0))
334+
})
335+
336+
return Service.of({
337+
files: (input) => Stream.unwrap(files(input)),
338+
})
339+
}),
340+
)
341+
342+
export const defaultLayer = layer.pipe(
343+
Layer.provide(AppFileSystem.defaultLayer),
344+
Layer.provide(CrossSpawnSpawner.defaultLayer),
345+
)
346+
277347
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
278348
log.info("tree", input)
279349
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))

packages/opencode/src/tool/glob.ts

Lines changed: 80 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,92 @@
11
import z from "zod"
22
import path from "path"
3+
import { Effect, Option } from "effect"
4+
import * as Stream from "effect/Stream"
35
import { Tool } from "./tool"
4-
import { Filesystem } from "../util/filesystem"
56
import DESCRIPTION from "./glob.txt"
67
import { Ripgrep } from "../file/ripgrep"
78
import { Instance } from "../project/instance"
8-
import { assertExternalDirectory } from "./external-directory"
9+
import { assertExternalDirectoryEffect } from "./external-directory"
10+
import { AppFileSystem } from "../filesystem"
911

10-
export const GlobTool = Tool.define("glob", {
11-
description: DESCRIPTION,
12-
parameters: z.object({
13-
pattern: z.string().describe("The glob pattern to match files against"),
14-
path: z
15-
.string()
16-
.optional()
17-
.describe(
18-
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
19-
),
20-
}),
21-
async execute(params, ctx) {
22-
await ctx.ask({
23-
permission: "glob",
24-
patterns: [params.pattern],
25-
always: ["*"],
26-
metadata: {
27-
pattern: params.pattern,
28-
path: params.path,
29-
},
30-
})
12+
export const GlobTool = Tool.defineEffect(
13+
"glob",
14+
Effect.gen(function* () {
15+
const rg = yield* Ripgrep.Service
16+
const fs = yield* AppFileSystem.Service
3117

32-
let search = params.path ?? Instance.directory
33-
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
34-
await assertExternalDirectory(ctx, search, { kind: "directory" })
18+
return {
19+
description: DESCRIPTION,
20+
parameters: z.object({
21+
pattern: z.string().describe("The glob pattern to match files against"),
22+
path: z
23+
.string()
24+
.optional()
25+
.describe(
26+
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
27+
),
28+
}),
29+
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
30+
Effect.gen(function* () {
31+
yield* Effect.promise(() =>
32+
ctx.ask({
33+
permission: "glob",
34+
patterns: [params.pattern],
35+
always: ["*"],
36+
metadata: {
37+
pattern: params.pattern,
38+
path: params.path,
39+
},
40+
}),
41+
)
3542

36-
const limit = 100
37-
const files = []
38-
let truncated = false
39-
for await (const file of Ripgrep.files({
40-
cwd: search,
41-
glob: [params.pattern],
42-
signal: ctx.abort,
43-
})) {
44-
if (files.length >= limit) {
45-
truncated = true
46-
break
47-
}
48-
const full = path.resolve(search, file)
49-
const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0
50-
files.push({
51-
path: full,
52-
mtime: stats,
53-
})
54-
}
55-
files.sort((a, b) => b.mtime - a.mtime)
43+
let search = params.path ?? Instance.directory
44+
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
45+
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
5646

57-
const output = []
58-
if (files.length === 0) output.push("No files found")
59-
if (files.length > 0) {
60-
output.push(...files.map((f) => f.path))
61-
if (truncated) {
62-
output.push("")
63-
output.push(
64-
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
65-
)
66-
}
67-
}
47+
const limit = 100
48+
let truncated = false
49+
const files = yield* rg.files({ cwd: search, glob: [params.pattern] }).pipe(
50+
Stream.mapEffect((file) =>
51+
Effect.gen(function* () {
52+
const full = path.resolve(search, file)
53+
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
54+
const mtime = info?.mtime.pipe(Option.map((d) => d.getTime()), Option.getOrElse(() => 0)) ?? 0
55+
return { path: full, mtime }
56+
}),
57+
),
58+
Stream.take(limit + 1),
59+
Stream.runCollect,
60+
Effect.map((chunk) => [...chunk]),
61+
)
6862

69-
return {
70-
title: path.relative(Instance.worktree, search),
71-
metadata: {
72-
count: files.length,
73-
truncated,
74-
},
75-
output: output.join("\n"),
63+
if (files.length > limit) {
64+
truncated = true
65+
files.length = limit
66+
}
67+
files.sort((a, b) => b.mtime - a.mtime)
68+
69+
const output = []
70+
if (files.length === 0) output.push("No files found")
71+
if (files.length > 0) {
72+
output.push(...files.map((f) => f.path))
73+
if (truncated) {
74+
output.push("")
75+
output.push(
76+
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
77+
)
78+
}
79+
}
80+
81+
return {
82+
title: path.relative(Instance.worktree, search),
83+
metadata: {
84+
count: files.length,
85+
truncated,
86+
},
87+
output: output.join("\n"),
88+
}
89+
}).pipe(Effect.orDie, Effect.runPromise),
7690
}
77-
},
78-
})
91+
}),
92+
)

packages/opencode/src/tool/registry.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { Effect, Layer, ServiceMap } from "effect"
3333
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
3434
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
3535
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
36+
import { Ripgrep } from "../file/ripgrep"
3637
import { InstanceState } from "@/effect/instance-state"
3738
import { makeRuntime } from "@/effect/run-service"
3839
import { Env } from "../env"
@@ -89,6 +90,7 @@ export namespace ToolRegistry {
8990
| AppFileSystem.Service
9091
| HttpClient.HttpClient
9192
| ChildProcessSpawner
93+
| Ripgrep.Service
9294
> = Layer.effect(
9395
Service,
9496
Effect.gen(function* () {
@@ -107,6 +109,7 @@ export namespace ToolRegistry {
107109
const websearch = yield* WebSearchTool
108110
const bash = yield* BashTool
109111
const codesearch = yield* CodeSearchTool
112+
const globtool = yield* GlobTool
110113

111114
const state = yield* InstanceState.make<State>(
112115
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -167,7 +170,7 @@ export namespace ToolRegistry {
167170
invalid: Tool.init(InvalidTool),
168171
bash: Tool.init(bash),
169172
read: Tool.init(read),
170-
glob: Tool.init(GlobTool),
173+
glob: Tool.init(globtool),
171174
grep: Tool.init(GrepTool),
172175
edit: Tool.init(EditTool),
173176
write: Tool.init(WriteTool),
@@ -320,6 +323,7 @@ export namespace ToolRegistry {
320323
Layer.provide(AppFileSystem.defaultLayer),
321324
Layer.provide(FetchHttpClient.layer),
322325
Layer.provide(CrossSpawnSpawner.defaultLayer),
326+
Layer.provide(Ripgrep.defaultLayer),
323327
),
324328
)
325329

packages/opencode/test/file/ripgrep.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { describe, expect, test } from "bun:test"
2+
import { Effect } from "effect"
3+
import * as Stream from "effect/Stream"
24
import fs from "fs/promises"
35
import path from "path"
46
import { tmpdir } from "../fixture/fixture"
@@ -52,3 +54,46 @@ describe("file.ripgrep", () => {
5254
expect(hits).toEqual([])
5355
})
5456
})
57+
58+
describe("Ripgrep.Service", () => {
59+
test("files returns stream of filenames", async () => {
60+
await using tmp = await tmpdir({
61+
init: async (dir) => {
62+
await Bun.write(path.join(dir, "a.txt"), "hello")
63+
await Bun.write(path.join(dir, "b.txt"), "world")
64+
},
65+
})
66+
67+
const files = await Effect.gen(function* () {
68+
const rg = yield* Ripgrep.Service
69+
return yield* rg.files({ cwd: tmp.path }).pipe(Stream.runCollect, Effect.map((chunk) => [...chunk].sort()))
70+
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
71+
72+
expect(files).toEqual(["a.txt", "b.txt"])
73+
})
74+
75+
test("files respects glob filter", async () => {
76+
await using tmp = await tmpdir({
77+
init: async (dir) => {
78+
await Bun.write(path.join(dir, "keep.ts"), "yes")
79+
await Bun.write(path.join(dir, "skip.txt"), "no")
80+
},
81+
})
82+
83+
const files = await Effect.gen(function* () {
84+
const rg = yield* Ripgrep.Service
85+
return yield* rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe(Stream.runCollect, Effect.map((chunk) => [...chunk]))
86+
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
87+
88+
expect(files).toEqual(["keep.ts"])
89+
})
90+
91+
test("files dies on nonexistent directory", async () => {
92+
const exit = await Effect.gen(function* () {
93+
const rg = yield* Ripgrep.Service
94+
return yield* rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect)
95+
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit)
96+
97+
expect(exit._tag).toBe("Failure")
98+
})
99+
})

packages/opencode/test/session/prompt-effect.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { ToolRegistry } from "../../src/tool/registry"
3737
import { Truncate } from "../../src/tool/truncate"
3838
import { Log } from "../../src/util/log"
3939
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
40+
import { Ripgrep } from "../../src/file/ripgrep"
4041
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
4142
import { testEffect } from "../lib/effect"
4243
import { reply, TestLLMServer } from "../lib/llm-server"
@@ -172,6 +173,7 @@ function makeHttp() {
172173
Layer.provide(Skill.defaultLayer),
173174
Layer.provide(FetchHttpClient.layer),
174175
Layer.provide(CrossSpawnSpawner.defaultLayer),
176+
Layer.provide(Ripgrep.defaultLayer),
175177
Layer.provideMerge(todo),
176178
Layer.provideMerge(question),
177179
Layer.provideMerge(deps),

packages/opencode/test/session/snapshot-tool-race.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { ToolRegistry } from "../../src/tool/registry"
5353
import { Truncate } from "../../src/tool/truncate"
5454
import { AppFileSystem } from "../../src/filesystem"
5555
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
56+
import { Ripgrep } from "../../src/file/ripgrep"
5657

5758
Log.init({ print: false })
5859

@@ -136,6 +137,7 @@ function makeHttp() {
136137
Layer.provide(Skill.defaultLayer),
137138
Layer.provide(FetchHttpClient.layer),
138139
Layer.provide(CrossSpawnSpawner.defaultLayer),
140+
Layer.provide(Ripgrep.defaultLayer),
139141
Layer.provideMerge(todo),
140142
Layer.provideMerge(question),
141143
Layer.provideMerge(deps),

0 commit comments

Comments
 (0)