mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 10:16:03 +00:00
285 lines
9.8 KiB
TypeScript
285 lines
9.8 KiB
TypeScript
export * as Ripgrep from "./ripgrep"
|
|
|
|
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
|
import { ChildProcess } from "effect/unstable/process"
|
|
import path from "path"
|
|
import { Entry, Match } from "./filesystem/schema"
|
|
import { FSUtil } from "./fs-util"
|
|
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,
|
|
}),
|
|
),
|
|
}),
|
|
})
|
|
|
|
type RawMatchData = (typeof RawMatch.Type)["data"]
|
|
|
|
export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
|
|
message: Schema.String,
|
|
cause: Schema.optional(Schema.Defect),
|
|
}) {}
|
|
|
|
export class InvalidPatternError extends Schema.TaggedErrorClass<InvalidPatternError>()("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
|
|
}
|
|
|
|
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 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/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* 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
|
|
}) => {
|
|
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({
|
|
glob: (input) =>
|
|
run<string>({
|
|
cwd: input.cwd,
|
|
limit: input.limit,
|
|
signal: input.signal,
|
|
args: [
|
|
"--no-config",
|
|
"--files",
|
|
"--glob=!**/.git/**",
|
|
...(input.hidden ? ["--hidden"] : []),
|
|
...(input.follow ? ["--follow"] : []),
|
|
`--glob=${input.pattern}`,
|
|
".",
|
|
],
|
|
parse: (line) =>
|
|
Effect.succeed(
|
|
line
|
|
.replace(/^(?:\.[\\/])+/u, "")
|
|
.replace(/^[\\/]+/u, "")
|
|
.replaceAll("\\", "/"),
|
|
),
|
|
}).pipe(
|
|
Effect.map((result) =>
|
|
result.items.map((relative) => {
|
|
const absolute = path.resolve(input.cwd, relative)
|
|
return new Entry({
|
|
path: RelativePath.make(relative),
|
|
type: "file",
|
|
mime: FSUtil.mimeType(absolute),
|
|
})
|
|
}),
|
|
),
|
|
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
|
|
),
|
|
find: (input) =>
|
|
run<string>({
|
|
cwd: input.cwd,
|
|
limit: input.limit,
|
|
signal: input.signal,
|
|
args: [
|
|
"--no-config",
|
|
"--files",
|
|
"--glob=!**/.git/**",
|
|
...(input.hidden ? ["--hidden"] : []),
|
|
...(input.follow ? ["--follow"] : []),
|
|
`--glob=${input.pattern}`,
|
|
".",
|
|
],
|
|
parse: (line) =>
|
|
Effect.succeed(
|
|
line
|
|
.replace(/^(?:\.[\\/])+/u, "")
|
|
.replace(/^[\\/]+/u, "")
|
|
.replaceAll("\\", "/"),
|
|
),
|
|
}).pipe(
|
|
Effect.map((result) =>
|
|
result.items.map((relative) => {
|
|
const absolute = path.resolve(input.cwd, relative)
|
|
return new Entry({
|
|
path: RelativePath.make(relative),
|
|
type: "file",
|
|
mime: FSUtil.mimeType(absolute),
|
|
})
|
|
}),
|
|
),
|
|
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
|
|
),
|
|
grep: (input) =>
|
|
run<RawMatchData>({
|
|
...input,
|
|
args: [
|
|
"--no-config",
|
|
"--json",
|
|
"--hidden",
|
|
"--glob=!**/.git/**",
|
|
"--no-messages",
|
|
...(input.include ? [`--glob=${input.include}`] : []),
|
|
"--",
|
|
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,
|
|
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("\\", "/")
|
|
const absolute = path.resolve(input.cwd, relative)
|
|
return new Match({
|
|
entry: new Entry({
|
|
path: RelativePath.make(relative),
|
|
type: "file",
|
|
mime: FSUtil.mimeType(absolute),
|
|
}),
|
|
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 defaultLayer = layer.pipe(
|
|
Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)),
|
|
)
|