export * as Ripgrep from "./ripgrep" import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { Ripgrep as FileSystemRipgrep } from "./filesystem/ripgrep" import { AppProcess, collectStream, waitForAbort } from "./process" import { NonNegativeInt, PositiveInt } from "./schema" /** * Small core-owned ripgrep execution adapter. It deliberately exposes raw * process-oriented rows, not model text or permission behavior. LocationSearch * supplies read authority and bounded substrate results; future leaf tools own * presentation and permission prompts. */ const ERROR_BYTES = 8 * 1024 export const MAX_RECORD_BYTES = 64 * 1024 export 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, }), ), }), }) export type Match = (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 Result { readonly items: A[] readonly truncated: boolean readonly partial: boolean } export interface FilesInput { readonly cwd: string readonly pattern: string readonly limit: number 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 files: (input: FilesInput) => Effect.Effect, Error> readonly grep: (input: GrepInput) => Effect.Effect, Error | InvalidPatternError> } 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") export const layer = Layer.effect( Service, Effect.gen(function* () { const process = yield* AppProcess.Service const binary = yield* FileSystemRipgrep.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 }) => { 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, ) 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.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({ files: (input) => run({ ...input, args: [ "--no-config", "--files", "--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure. `--glob=${input.pattern}`, "--glob=!.*", "--glob=!**/.*", ".", ], parse: (line) => Effect.succeed(line.replace(/^\.\//, "")), }).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))), grep: (input) => run({ ...input, args: [ "--no-config", "--json", "--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure. "--no-messages", ...(input.include ? [`--glob=${input.include}`] : []), "--glob=!.*", "--glob=!**/.*", "--", input.pattern, input.file ?? ".", ], parse: (line) => (Buffer.byteLength(line, "utf8") > MAX_RECORD_BYTES ? Effect.fail(failure(`Ripgrep JSON record exceeded ${MAX_RECORD_BYTES} bytes`)) : Effect.try({ try: () => JSON.parse(line) as unknown, catch: (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, submatches: match.data.submatches.slice(0, MAX_SUBMATCHES), })), Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)), ) }), ), }), }) }), ).pipe(Layer.provide(FileSystemRipgrep.defaultLayer))