diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index d5e24a0cfa..8e4eaa4e4d 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -1,10 +1,9 @@ import { EOL } from "os" -import { Effect } from "effect" import { AppRuntime } from "@/effect/app-runtime" import { File } from "../../../file" +import { Ripgrep } from "@/file/ripgrep" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" -import { Ripgrep } from "@/file/ripgrep" const FileSearchCommand = cmd({ command: "search ", @@ -17,11 +16,7 @@ const FileSearchCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - const results = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.search({ query: args.query })) - }), - ) + const results = await AppRuntime.runPromise(File.Service.use((svc) => svc.search({ query: args.query }))) process.stdout.write(results.join(EOL) + EOL) }) }, @@ -38,11 +33,7 @@ const FileReadCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - const content = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.read(args.path)) - }), - ) + const content = await AppRuntime.runPromise(File.Service.use((svc) => svc.read(args.path))) process.stdout.write(JSON.stringify(content, null, 2) + EOL) }) }, @@ -54,11 +45,7 @@ const FileStatusCommand = cmd({ builder: (yargs) => yargs, async handler() { await bootstrap(process.cwd(), async () => { - const status = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.status()) - }), - ) + const status = await AppRuntime.runPromise(File.Service.use((svc) => svc.status())) process.stdout.write(JSON.stringify(status, null, 2) + EOL) }) }, @@ -75,11 +62,7 @@ const FileListCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - const files = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.list(args.path)) - }), - ) + const files = await AppRuntime.runPromise(File.Service.use((svc) => svc.list(args.path))) process.stdout.write(JSON.stringify(files, null, 2) + EOL) }) }, @@ -95,8 +78,10 @@ const FileTreeCommand = cmd({ default: process.cwd(), }), async handler(args) { - const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 }) - console.log(JSON.stringify(files, null, 2)) + await bootstrap(process.cwd(), async () => { + const tree = await AppRuntime.runPromise(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) + console.log(JSON.stringify(tree, null, 2)) + }) }, }) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index 8c994d6e52..9b7e826915 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -1,4 +1,5 @@ import { EOL } from "os" +import { Effect, Stream } from "effect" import { AppRuntime } from "../../../effect/app-runtime" import { Ripgrep } from "../../../file/ripgrep" import { Instance } from "../../../project/instance" @@ -21,7 +22,10 @@ const TreeCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - process.stdout.write((await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit })) + EOL) + const tree = await AppRuntime.runPromise( + Ripgrep.Service.use((svc) => svc.tree({ cwd: Instance.directory, limit: args.limit })), + ) + process.stdout.write(tree + EOL) }) }, }) @@ -45,14 +49,21 @@ const FilesCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - const files: string[] = [] - for await (const file of await Ripgrep.files({ - cwd: Instance.directory, - glob: args.glob ? [args.glob] : undefined, - })) { - files.push(file) - if (args.limit && files.length >= args.limit) break - } + const files = await AppRuntime.runPromise( + Effect.gen(function* () { + const rg = yield* Ripgrep.Service + return yield* rg + .files({ + cwd: Instance.directory, + glob: args.glob ? [args.glob] : undefined, + }) + .pipe( + Stream.take(args.limit ?? Infinity), + Stream.runCollect, + Effect.map((c) => [...c]), + ) + }), + ) process.stdout.write(files.join(EOL) + EOL) }) }, diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 113dc59096..909f1e61d2 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" + import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Git } from "@/git" import { Effect, Layer, Context } from "effect" @@ -653,26 +653,4 @@ export namespace File { Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer), ) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export function init() { - return runPromise((svc) => svc.init()) - } - - export async function status() { - return runPromise((svc) => svc.status()) - } - - export async function read(file: string): Promise { - return runPromise((svc) => svc.read(file)) - } - - export async function list(dir?: string) { - return runPromise((svc) => svc.list(dir)) - } - - export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { - return runPromise((svc) => svc.search(input)) - } } diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index abf7438dcc..fee9cf4430 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "url" import z from "zod" import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" import { ripgrep } from "ripgrep" -import { makeRuntime } from "@/effect/run-service" + import { Filesystem } from "@/util/filesystem" import { Log } from "@/util/log" @@ -572,18 +572,4 @@ export namespace Ripgrep { ) export const defaultLayer = layer - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export function files(input: FilesInput) { - return runPromise((svc) => Stream.toAsyncIterableEffect(svc.files(input))) - } - - export function tree(input: TreeInput) { - return runPromise((svc) => svc.tree(input)) - } - - export function search(input: SearchInput) { - return runPromise((svc) => svc.search(input)) - } } diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts index c3575fdf85..a76c7ebe26 100644 --- a/packages/opencode/test/file/ripgrep.test.ts +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -6,20 +6,8 @@ import path from "path" import { tmpdir } from "../fixture/fixture" import { Ripgrep } from "../../src/file/ripgrep" -async function seed(dir: string, count: number, size = 16) { - const txt = "a".repeat(size) - await Promise.all(Array.from({ length: count }, (_, i) => Bun.write(path.join(dir, `file-${i}.txt`), `${txt}${i}\n`))) -} - -function env(name: string, value: string | undefined) { - const prev = process.env[name] - if (value === undefined) delete process.env[name] - else process.env[name] = value - return () => { - if (prev === undefined) delete process.env[name] - else process.env[name] = prev - } -} +const run = (effect: Effect.Effect) => + effect.pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) describe("file.ripgrep", () => { test("defaults to include hidden", async () => { @@ -31,7 +19,14 @@ describe("file.ripgrep", () => { }, }) - const files = await Array.fromAsync(await Ripgrep.files({ cwd: tmp.path })) + const files = await run( + Ripgrep.Service.use((rg) => + rg.files({ cwd: tmp.path }).pipe( + Stream.runCollect, + Effect.map((c) => [...c]), + ), + ), + ) expect(files.includes("visible.txt")).toBe(true) expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true) }) @@ -45,7 +40,14 @@ describe("file.ripgrep", () => { }, }) - const files = await Array.fromAsync(await Ripgrep.files({ cwd: tmp.path, hidden: false })) + const files = await run( + Ripgrep.Service.use((rg) => + rg.files({ cwd: tmp.path, hidden: false }).pipe( + Stream.runCollect, + Effect.map((c) => [...c]), + ), + ), + ) expect(files.includes("visible.txt")).toBe(true) expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false) }) @@ -57,7 +59,7 @@ describe("file.ripgrep", () => { }, }) - const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" }) + const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) expect(result.partial).toBe(false) expect(result.items).toEqual([]) }) @@ -70,7 +72,7 @@ describe("file.ripgrep", () => { }, }) - const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" }) + const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) expect(result.partial).toBe(false) expect(result.items).toHaveLength(1) expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts")) @@ -78,99 +80,7 @@ describe("file.ripgrep", () => { expect(result.items[0]?.lines.text).toContain("needle") }) - test("files returns empty when glob matches no files in worker mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await fs.mkdir(path.join(dir, "packages", "console"), { recursive: true }) - await Bun.write(path.join(dir, "packages", "console", "package.json"), "{}") - }, - }) - - const ctl = new AbortController() - const files = await Array.fromAsync( - await Ripgrep.files({ - cwd: tmp.path, - glob: ["packages/*"], - signal: ctl.signal, - }), - ) - - expect(files).toEqual([]) - }) - - test("ignores RIPGREP_CONFIG_PATH in direct mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n") - }, - }) - - const restore = env("RIPGREP_CONFIG_PATH", path.join(tmp.path, "missing-ripgreprc")) - try { - const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" }) - expect(result.items).toHaveLength(1) - } finally { - restore() - } - }) - - test("ignores RIPGREP_CONFIG_PATH in worker mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n") - }, - }) - - const restore = env("RIPGREP_CONFIG_PATH", path.join(tmp.path, "missing-ripgreprc")) - try { - const ctl = new AbortController() - const result = await Ripgrep.search({ - cwd: tmp.path, - pattern: "needle", - signal: ctl.signal, - }) - expect(result.items).toHaveLength(1) - } finally { - restore() - } - }) - - test("aborts files scan in worker mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await seed(dir, 4000) - }, - }) - - const ctl = new AbortController() - const iter = await Ripgrep.files({ cwd: tmp.path, signal: ctl.signal }) - const pending = Array.fromAsync(iter) - setTimeout(() => ctl.abort(), 0) - - const err = await pending.catch((err) => err) - expect(err).toBeInstanceOf(Error) - expect(err.name).toBe("AbortError") - }, 15_000) - - test("aborts search in worker mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await seed(dir, 512, 64 * 1024) - }, - }) - - const ctl = new AbortController() - const pending = Ripgrep.search({ cwd: tmp.path, pattern: "needle", signal: ctl.signal }) - setTimeout(() => ctl.abort(), 0) - - const err = await pending.catch((err) => err) - expect(err).toBeInstanceOf(Error) - expect(err.name).toBe("AbortError") - }, 15_000) -}) - -describe("Ripgrep.Service", () => { - test("search returns matched rows", async () => { + test("search returns matched rows with glob filter", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n") @@ -178,11 +88,9 @@ describe("Ripgrep.Service", () => { }, }) - const result = await Effect.gen(function* () { - const rg = yield* Ripgrep.Service - return yield* rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] }) - }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) - + const result = await run( + Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] })), + ) expect(result.partial).toBe(false) expect(result.items).toHaveLength(1) expect(result.items[0]?.path.text).toContain("match.ts") @@ -198,16 +106,31 @@ describe("Ripgrep.Service", () => { }) const file = path.join(tmp.path, "match.ts") - const result = await Effect.gen(function* () { - const rg = yield* Ripgrep.Service - return yield* rg.search({ cwd: tmp.path, pattern: "needle", file: [file] }) - }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) - + const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle", file: [file] }))) expect(result.partial).toBe(false) expect(result.items).toHaveLength(1) expect(result.items[0]?.path.text).toBe(file) }) + test("files returns empty when glob matches no files", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "packages", "console"), { recursive: true }) + await Bun.write(path.join(dir, "packages", "console", "package.json"), "{}") + }, + }) + + const files = await run( + Ripgrep.Service.use((rg) => + rg.files({ cwd: tmp.path, glob: ["packages/*"] }).pipe( + Stream.runCollect, + Effect.map((c) => [...c]), + ), + ), + ) + expect(files).toEqual([]) + }) + test("files returns stream of filenames", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -216,14 +139,14 @@ describe("Ripgrep.Service", () => { }, }) - 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) - + const files = await run( + Ripgrep.Service.use((rg) => + rg.files({ cwd: tmp.path }).pipe( + Stream.runCollect, + Effect.map((c) => [...c].sort()), + ), + ), + ) expect(files).toEqual(["a.txt", "b.txt"]) }) @@ -235,23 +158,57 @@ describe("Ripgrep.Service", () => { }, }) - 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) - + const files = await run( + Ripgrep.Service.use((rg) => + rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe( + Stream.runCollect, + Effect.map((c) => [...c]), + ), + ), + ) 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) - + const exit = await Ripgrep.Service.use((rg) => + rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect), + ).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit) expect(exit._tag).toBe("Failure") }) + + test("ignores RIPGREP_CONFIG_PATH in direct mode", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n") + }, + }) + + const prev = process.env["RIPGREP_CONFIG_PATH"] + process.env["RIPGREP_CONFIG_PATH"] = path.join(tmp.path, "missing-ripgreprc") + try { + const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) + expect(result.items).toHaveLength(1) + } finally { + if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"] + else process.env["RIPGREP_CONFIG_PATH"] = prev + } + }) + + test("ignores RIPGREP_CONFIG_PATH in worker mode", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n") + }, + }) + + const prev = process.env["RIPGREP_CONFIG_PATH"] + process.env["RIPGREP_CONFIG_PATH"] = path.join(tmp.path, "missing-ripgreprc") + try { + const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) + expect(result.items).toHaveLength(1) + } finally { + if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"] + else process.env["RIPGREP_CONFIG_PATH"] = prev + } + }) })