export * as Ripgrep from "./ripgrep" import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { Entry, Match } from "@opencode-ai/schema/filesystem" import { makeGlobalNode } from "./effect/app-node" import { AppProcess, collectStream, waitForAbort } from "./process" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" import { RipgrepBinary } from "./ripgrep/binary" /** * Small core-owned ripgrep execution adapter. It deliberately exposes raw * process-oriented rows, not model text or permission behavior. Search maps * these rows into filesystem results; leaf tools own * presentation and permission prompts. */ const ERROR_BYTES = 8 * 1024 const MAX_RECORD_BYTES = 64 * 1024 const MAX_SUBMATCHES = 100 const RawMatch = Schema.Struct({ type: Schema.Literal("match"), data: Schema.Struct({ path: Schema.Struct({ text: Schema.String }), lines: Schema.Struct({ text: Schema.String }), line_number: PositiveInt, absolute_offset: NonNegativeInt, submatches: Schema.Array( Schema.Struct({ match: Schema.Struct({ text: Schema.String }), start: NonNegativeInt, end: NonNegativeInt, }), ), }), }) const decodeJsonRecord = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString) type RawMatchData = (typeof RawMatch.Type)["data"] export class Error extends Schema.TaggedErrorClass()("Ripgrep.Error", { message: Schema.String, cause: Schema.optional(Schema.Defect()), }) {} export class InvalidPatternError extends Schema.TaggedErrorClass()("Ripgrep.InvalidPatternError", { pattern: Schema.String, message: Schema.String, }) {} export interface FindInput { readonly cwd: string readonly pattern: string readonly limit: number readonly hidden?: boolean readonly follow?: boolean readonly signal?: AbortSignal readonly onEntry?: (entry: Entry) => Effect.Effect } export interface GlobInput { readonly cwd: string readonly pattern: string readonly limit: number readonly hidden?: boolean readonly follow?: boolean readonly signal?: AbortSignal } export interface GrepInput { readonly cwd: string readonly pattern: string readonly file?: string readonly include?: string readonly limit: number readonly signal?: AbortSignal } export interface Interface { readonly find: (input: FindInput) => Effect.Effect readonly glob: (input: GlobInput) => Effect.Effect readonly grep: (input: GrepInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Ripgrep") {} const failure = (message: string, cause?: unknown) => new Error({ message, cause }) const isInvalidPattern = (stderr: string) => stderr.includes("regex parse error") || stderr.includes("error parsing regex") const layer = Layer.effect( Service, Effect.gen(function* () { const process = yield* AppProcess.Service const binary = yield* RipgrepBinary.Service const run = (input: { readonly cwd: string readonly args: string[] readonly limit: number readonly signal?: AbortSignal readonly parse: (line: string) => Effect.Effect readonly pattern?: string readonly onItem?: (item: A) => Effect.Effect }) => { const program = Effect.scoped( Effect.gen(function* () { const handle = yield* process.spawn( ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }), ) const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe( Effect.map((output) => output.buffer.toString("utf8")), Effect.forkScoped, ) let observed = 0 const rows = yield* Stream.decodeText(handle.stdout).pipe( Stream.splitLines, Stream.filter((line) => line.length > 0), Stream.mapEffect(input.parse), Stream.filter((row): row is A => row !== undefined), Stream.tap((row) => { if (!input.onItem || observed++ >= input.limit) return Effect.void return input.onItem(row) }), Stream.take(input.limit + 1), Stream.runCollect, Effect.map((chunk) => [...chunk]), ) const truncated = rows.length > input.limit if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false } const code = yield* handle.exitCode const stderr = yield* Fiber.join(stderrFiber) if (input.pattern && code === 2 && isInvalidPattern(stderr)) { return yield* new InvalidPatternError({ pattern: input.pattern, message: stderr.trim() }) } if (code !== 0 && code !== 1 && code !== 2) { return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`) } return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 } }), ) const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program return abortable.pipe( Effect.mapError((cause) => cause instanceof Error || cause instanceof InvalidPatternError ? cause : failure("ripgrep execution failed", cause), ), ) } return Service.of({ glob: (input) => run({ cwd: input.cwd, limit: input.limit, signal: input.signal, args: [ "--no-config", "--files", ...(input.hidden ? ["--hidden"] : []), ...(input.follow ? ["--follow"] : []), `--glob=${input.pattern}`, "--glob=!**/.git/**", ".", ], parse: (line) => Effect.succeed( line .replace(/^(?:\.[\\/])+/u, "") .replace(/^[\\/]+/u, "") .replaceAll("\\", "/"), ), }).pipe( Effect.map((result) => result.items.map((relative) => Entry.make({ path: RelativePath.make(relative), type: "file", }), ), ), Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))), ), find: (input) => run({ cwd: input.cwd, limit: input.limit, signal: input.signal, args: [ "--no-config", "--files", ...(input.hidden ? ["--hidden"] : []), ...(input.follow ? ["--follow"] : []), ...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]), "--glob=!**/.git/**", ".", ], parse: (line) => { const relative = line .replace(/^(?:\.[\\/])+/u, "") .replace(/^[\\/]+/u, "") .replaceAll("\\", "/") return Effect.succeed( Entry.make({ path: RelativePath.make(relative), type: "file", }), ) }, onItem: input.onEntry, }).pipe( Effect.map((result) => result.items), Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))), ), grep: (input) => run({ ...input, args: [ "--no-config", "--json", "--hidden", "--no-messages", ...(input.include ? [`--glob=${input.include}`] : []), "--glob=!**/.git/**", "--", input.pattern, input.file ?? ".", ], parse: (line) => (Buffer.byteLength(line, "utf8") > MAX_RECORD_BYTES ? Effect.fail(failure(`Ripgrep JSON record exceeded ${MAX_RECORD_BYTES} bytes`)) : decodeJsonRecord(line).pipe(Effect.mapError((cause) => failure("Invalid ripgrep JSON output", cause))) ).pipe( Effect.flatMap((json) => { if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match") return Effect.succeed(undefined) return Schema.decodeUnknownEffect(RawMatch)(json).pipe( Effect.map((match) => ({ ...match.data, path: { text: match.data.path.text.replace(/^\.[\\/]/, "") }, submatches: match.data.submatches.slice(0, MAX_SUBMATCHES), })), Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)), ) }), ), }).pipe( Effect.map((result) => result.items.map((match) => { const relative = match.path.text .replace(/^(?:\.[\\/])+/u, "") .replace(/^[\\/]+/u, "") .replaceAll("\\", "/") return Match.make({ entry: Entry.make({ path: RelativePath.make(relative), type: "file", }), line: match.line_number, offset: match.absolute_offset, text: match.lines.text.length > 2_000 ? match.lines.text.slice(0, 2_000) + "..." : match.lines.text, submatches: match.submatches.map((submatch) => ({ text: submatch.match.text, start: submatch.start, end: submatch.end, })), }) }), ), ), }) }), ) export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })