Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276export * as Ripgrep from "./ripgrep.js"
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"import { ChildProcess } from "effect/unstable/process"import { Entry, Match } from "@opencode/schema/filesystem"import { makeLocationNode } from "@opencode/util/effect/app-node"import { collectStream, waitForAbort } from "@opencode/util/process"import { Environment } from "./environment/index.js"import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"import { RipgrepBinary } from "./ripgrep/binary.js"
/** * 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 * 1024const 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.fromJsonString(Schema.Unknown))
type RawMatchData = (typeof RawMatch.Type)["data"]
export class Error extends Schema.TaggedError<Error>()("Ripgrep.Error", { message: Schema.String, cause: Schema.optional(Schema.Defect()),}) {}
export class InvalidPatternError extends Schema.TaggedError<InvalidPatternError>()("Ripgrep.InvalidPatternError", { pattern: Schema.String, message: Schema.String,}) {}
export interface FindInput { readonly cwd: string readonly pattern: string readonly limit: number readonly exclude?: readonly string[] readonly hidden?: boolean readonly follow?: boolean readonly signal?: AbortSignal readonly onEntry?: (entry: Entry) => Effect.Effect<void>}
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 literal?: boolean readonly caseSensitive?: boolean readonly limit: number readonly signal?: AbortSignal}
export interface Interface { readonly find: (input: FindInput) => Effect.Effect<readonly Entry[], Error> readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[], Error> readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[], Error | InvalidPatternError>}
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
const failure = (message: string, cause?: unknown) => new Error({ message, cause })
const normalizePath = (value: string) => value .replace(/^(?:\.[\\/])+/u, "") .replace(/^[\\/]+/u, "") .replaceAll("\\", "/")
const isInvalidPattern = (stderr: string) => stderr.includes("regex parse error") || stderr.includes("error parsing regex")
const layer = Layer.effect( Service, Effect.gen(function* () { const environment = yield* Environment.Service const binary = yield* RipgrepBinary.Service
const run = <A>(input: { readonly cwd: string readonly args: string[] readonly limit: number readonly signal?: AbortSignal readonly parse: (line: string) => Effect.Effect<A | undefined, Error> readonly pattern?: string readonly onItem?: (item: A) => Effect.Effect<void> }) => { const program = Effect.scoped( Effect.gen(function* () { // Hosted environments will resolve rg through their driver image; the spawner is the execution seam. const handle = yield* environment.spawner.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, ) const truncated = rows.length > input.limit if (truncated) return rows.slice(0, input.limit)
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 code === 1 ? [] : rows }), ) 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<string>({ cwd: input.cwd, limit: input.limit, signal: input.signal, args: [ "--no-config", "--files", ...(input.hidden ? ["--hidden"] : []), ...(input.follow ? ["--follow"] : []), `--glob=${input.pattern}`, // Positive globs override rg's hidden-file filter; exclude before applying the result limit. ...(input.hidden ? [] : ["--glob=!**/.*"]), "--glob=!**/.git/**", ".", ], parse: (line) => Effect.succeed(normalizePath(line)), }).pipe( Effect.map((result) => result.map((relative) => Entry.make({ path: RelativePath.make(relative), type: "file", }), ), ), Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))), ), find: (input) => run<Entry>({ cwd: input.cwd, limit: input.limit, signal: input.signal, args: [ "--no-config", "--files", ...(input.hidden ? ["--hidden"] : []), ...(input.follow ? ["--follow"] : []), ...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]), ...(input.exclude ?? []).map((pattern) => `--glob=!${pattern}`), "--glob=!**/.git/**", ".", ], parse: (line) => { const relative = normalizePath(line) return Effect.succeed( Entry.make({ path: RelativePath.make(relative), type: "file", }), ) }, onItem: input.onEntry, }).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))), grep: (input) => run<RawMatchData>({ ...input, args: [ "--no-config", "--json", "--hidden", "--no-messages", ...(input.literal ? ["--fixed-strings"] : []), ...(input.caseSensitive === false ? ["--ignore-case"] : []), ...(input.include ? [`--glob=${input.include}`] : []), "--glob=!**/.git/**", "--", input.pattern, input.file ?? ".", ], parse: (line) => decodeJsonRecord(line).pipe( Effect.mapError((cause) => failure("Invalid ripgrep JSON output", cause)), Effect.flatMap((json) => { if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match") return Effect.undefined return Schema.decodeUnknownEffect(RawMatch)(json).pipe( Effect.map((match) => ({ ...match.data, path: { text: normalizePath(match.data.path.text) }, submatches: match.data.submatches.slice(0, MAX_SUBMATCHES), })), Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)), ) }), ), }).pipe( Effect.map((result) => result.map((match) => Match.make({ entry: Entry.make({ path: RelativePath.make(match.path.text), 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 = makeLocationNode({ service: Service, layer, deps: [Environment.node, RipgrepBinary.node] })