diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 601c82e94f..8a2d9407a2 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -3,10 +3,17 @@ import path from "path" import { Global } from "../global" import fs from "fs/promises" import z from "zod" +import { Effect, Layer, ServiceMap } from "effect" +import * as Stream from "effect/Stream" +import { ChildProcess } from "effect/unstable/process" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import type { PlatformError } from "effect/PlatformError" import { NamedError } from "@opencode-ai/util/error" import { lazy } from "../util/lazy" import { Filesystem } from "../util/filesystem" +import { AppFileSystem } from "../filesystem" import { Process } from "../util/process" import { which } from "../util/which" import { text } from "node:stream/consumers" @@ -274,6 +281,69 @@ export namespace Ripgrep { input.signal?.throwIfAborted() } + export interface Interface { + readonly files: (input: { + cwd: string + glob?: string[] + hidden?: boolean + follow?: boolean + maxDepth?: number + }) => Stream.Stream + } + + export class Service extends ServiceMap.Service()("@opencode/Ripgrep") {} + + export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + const afs = yield* AppFileSystem.Service + + const files = Effect.fn("Ripgrep.files")(function* (input: { + cwd: string + glob?: string[] + hidden?: boolean + follow?: boolean + maxDepth?: number + }) { + const rgPath = yield* Effect.promise(() => filepath()) + const isDir = yield* afs.isDir(input.cwd) + if (!isDir) { + return yield* Effect.die( + Object.assign(new Error(`No such file or directory: '${input.cwd}'`), { + code: "ENOENT" as const, + errno: -2, + path: input.cwd, + }), + ) + } + + const args = [rgPath, "--files", "--glob=!.git/*"] + if (input.follow) args.push("--follow") + if (input.hidden !== false) args.push("--hidden") + if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) + if (input.glob) { + for (const g of input.glob) { + args.push(`--glob=${g}`) + } + } + + return spawner.streamLines( + ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }), + ).pipe(Stream.filter((line: string) => line.length > 0)) + }) + + return Service.of({ + files: (input) => Stream.unwrap(files(input)), + }) + }), + ) + + export const defaultLayer = layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + ) + export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) { log.info("tree", input) const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal })) diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index a2611246c6..180e20f605 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -1,78 +1,92 @@ import z from "zod" import path from "path" +import { Effect, Option } from "effect" +import * as Stream from "effect/Stream" import { Tool } from "./tool" -import { Filesystem } from "../util/filesystem" import DESCRIPTION from "./glob.txt" import { Ripgrep } from "../file/ripgrep" import { Instance } from "../project/instance" -import { assertExternalDirectory } from "./external-directory" +import { assertExternalDirectoryEffect } from "./external-directory" +import { AppFileSystem } from "../filesystem" -export const GlobTool = Tool.define("glob", { - description: DESCRIPTION, - parameters: z.object({ - pattern: z.string().describe("The glob pattern to match files against"), - path: z - .string() - .optional() - .describe( - `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.`, - ), - }), - async execute(params, ctx) { - await ctx.ask({ - permission: "glob", - patterns: [params.pattern], - always: ["*"], - metadata: { - pattern: params.pattern, - path: params.path, - }, - }) - - let search = params.path ?? Instance.directory - search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) - await assertExternalDirectory(ctx, search, { kind: "directory" }) - - const limit = 100 - const files = [] - let truncated = false - for await (const file of Ripgrep.files({ - cwd: search, - glob: [params.pattern], - signal: ctx.abort, - })) { - if (files.length >= limit) { - truncated = true - break - } - const full = path.resolve(search, file) - const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0 - files.push({ - path: full, - mtime: stats, - }) - } - files.sort((a, b) => b.mtime - a.mtime) - - const output = [] - if (files.length === 0) output.push("No files found") - if (files.length > 0) { - output.push(...files.map((f) => f.path)) - if (truncated) { - output.push("") - output.push( - `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`, - ) - } - } +export const GlobTool = Tool.defineEffect( + "glob", + Effect.gen(function* () { + const rg = yield* Ripgrep.Service + const fs = yield* AppFileSystem.Service return { - title: path.relative(Instance.worktree, search), - metadata: { - count: files.length, - truncated, - }, - output: output.join("\n"), + description: DESCRIPTION, + parameters: z.object({ + pattern: z.string().describe("The glob pattern to match files against"), + path: z + .string() + .optional() + .describe( + `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.`, + ), + }), + execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) => + Effect.gen(function* () { + yield* Effect.promise(() => + ctx.ask({ + permission: "glob", + patterns: [params.pattern], + always: ["*"], + metadata: { + pattern: params.pattern, + path: params.path, + }, + }), + ) + + let search = params.path ?? Instance.directory + search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) + yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" }) + + const limit = 100 + let truncated = false + const files = yield* rg.files({ cwd: search, glob: [params.pattern] }).pipe( + Stream.mapEffect((file) => + Effect.gen(function* () { + const full = path.resolve(search, file) + const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined))) + const mtime = info?.mtime.pipe(Option.map((d) => d.getTime()), Option.getOrElse(() => 0)) ?? 0 + return { path: full, mtime } + }), + ), + Stream.take(limit + 1), + Stream.runCollect, + Effect.map((chunk) => [...chunk]), + ) + + if (files.length > limit) { + truncated = true + files.length = limit + } + files.sort((a, b) => b.mtime - a.mtime) + + const output = [] + if (files.length === 0) output.push("No files found") + if (files.length > 0) { + output.push(...files.map((f) => f.path)) + if (truncated) { + output.push("") + output.push( + `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`, + ) + } + } + + return { + title: path.relative(Instance.worktree, search), + metadata: { + count: files.length, + truncated, + }, + output: output.join("\n"), + } + }).pipe(Effect.orDie, Effect.runPromise), } - }, -}) + }), +) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index e47eb744ea..7f566ecd00 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -33,6 +33,7 @@ import { Effect, Layer, ServiceMap } from "effect" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { Ripgrep } from "../file/ripgrep" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { Env } from "../env" @@ -89,6 +90,7 @@ export namespace ToolRegistry { | AppFileSystem.Service | HttpClient.HttpClient | ChildProcessSpawner + | Ripgrep.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -107,6 +109,7 @@ export namespace ToolRegistry { const websearch = yield* WebSearchTool const bash = yield* BashTool const codesearch = yield* CodeSearchTool + const globtool = yield* GlobTool const state = yield* InstanceState.make( Effect.fn("ToolRegistry.state")(function* (ctx) { @@ -167,7 +170,7 @@ export namespace ToolRegistry { invalid: Tool.init(InvalidTool), bash: Tool.init(bash), read: Tool.init(read), - glob: Tool.init(GlobTool), + glob: Tool.init(globtool), grep: Tool.init(GrepTool), edit: Tool.init(EditTool), write: Tool.init(WriteTool), @@ -320,6 +323,7 @@ export namespace ToolRegistry { Layer.provide(AppFileSystem.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Ripgrep.defaultLayer), ), ) diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts index 5eb56e53de..03c529f18b 100644 --- a/packages/opencode/test/file/ripgrep.test.ts +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import * as Stream from "effect/Stream" import fs from "fs/promises" import path from "path" import { tmpdir } from "../fixture/fixture" @@ -52,3 +54,46 @@ describe("file.ripgrep", () => { expect(hits).toEqual([]) }) }) + +describe("Ripgrep.Service", () => { + test("files returns stream of filenames", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "a.txt"), "hello") + await Bun.write(path.join(dir, "b.txt"), "world") + }, + }) + + const files = await Effect.gen(function* () { + const rg = yield* Ripgrep.Service + return yield* rg.files({ cwd: tmp.path }).pipe(Stream.runCollect, Effect.map((chunk) => [...chunk].sort())) + }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) + + expect(files).toEqual(["a.txt", "b.txt"]) + }) + + test("files respects glob filter", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "keep.ts"), "yes") + await Bun.write(path.join(dir, "skip.txt"), "no") + }, + }) + + const files = await Effect.gen(function* () { + const rg = yield* Ripgrep.Service + return yield* rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe(Stream.runCollect, Effect.map((chunk) => [...chunk])) + }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) + + expect(files).toEqual(["keep.ts"]) + }) + + test("files dies on nonexistent directory", async () => { + const exit = await Effect.gen(function* () { + const rg = yield* Ripgrep.Service + return yield* rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect) + }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit) + + expect(exit._tag).toBe("Failure") + }) +}) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index c26a5fd033..aef88b2334 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -37,6 +37,7 @@ import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import { Log } from "../../src/util/log" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Ripgrep } from "../../src/file/ripgrep" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" @@ -172,6 +173,7 @@ function makeHttp() { Layer.provide(Skill.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Ripgrep.defaultLayer), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 911acba314..10d4d8f6f6 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -53,6 +53,7 @@ import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import { AppFileSystem } from "../../src/filesystem" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Ripgrep } from "../../src/file/ripgrep" Log.init({ print: false }) @@ -136,6 +137,7 @@ function makeHttp() { Layer.provide(Skill.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Ripgrep.defaultLayer), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps),