ripgrep.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. export * as Ripgrep from "./ripgrep"
  2. import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
  3. import { ChildProcess } from "effect/unstable/process"
  4. import { Ripgrep as FileSystemRipgrep } from "./filesystem/ripgrep"
  5. import { AppProcess, collectStream, waitForAbort } from "./process"
  6. import { NonNegativeInt, PositiveInt } from "./schema"
  7. /**
  8. * Small core-owned ripgrep execution adapter. It deliberately exposes raw
  9. * process-oriented rows, not model text or permission behavior. LocationSearch
  10. * supplies read authority and bounded substrate results; future leaf tools own
  11. * presentation and permission prompts.
  12. */
  13. const ERROR_BYTES = 8 * 1024
  14. export const MAX_RECORD_BYTES = 64 * 1024
  15. export const MAX_SUBMATCHES = 100
  16. const RawMatch = Schema.Struct({
  17. type: Schema.Literal("match"),
  18. data: Schema.Struct({
  19. path: Schema.Struct({ text: Schema.String }),
  20. lines: Schema.Struct({ text: Schema.String }),
  21. line_number: PositiveInt,
  22. absolute_offset: NonNegativeInt,
  23. submatches: Schema.Array(
  24. Schema.Struct({
  25. match: Schema.Struct({ text: Schema.String }),
  26. start: NonNegativeInt,
  27. end: NonNegativeInt,
  28. }),
  29. ),
  30. }),
  31. })
  32. export type Match = (typeof RawMatch.Type)["data"]
  33. export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
  34. message: Schema.String,
  35. cause: Schema.optional(Schema.Defect),
  36. }) {}
  37. export class InvalidPatternError extends Schema.TaggedErrorClass<InvalidPatternError>()("Ripgrep.InvalidPatternError", {
  38. pattern: Schema.String,
  39. message: Schema.String,
  40. }) {}
  41. export interface Result<A> {
  42. readonly items: A[]
  43. readonly truncated: boolean
  44. readonly partial: boolean
  45. }
  46. export interface FilesInput {
  47. readonly cwd: string
  48. readonly pattern: string
  49. readonly limit: number
  50. readonly signal?: AbortSignal
  51. }
  52. export interface GrepInput {
  53. readonly cwd: string
  54. readonly pattern: string
  55. readonly file?: string
  56. readonly include?: string
  57. readonly limit: number
  58. readonly signal?: AbortSignal
  59. }
  60. export interface Interface {
  61. readonly files: (input: FilesInput) => Effect.Effect<Result<string>, Error>
  62. readonly grep: (input: GrepInput) => Effect.Effect<Result<Match>, Error | InvalidPatternError>
  63. }
  64. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Ripgrep") {}
  65. const failure = (message: string, cause?: unknown) => new Error({ message, cause })
  66. const isInvalidPattern = (stderr: string) =>
  67. stderr.includes("regex parse error") || stderr.includes("error parsing regex")
  68. export const layer = Layer.effect(
  69. Service,
  70. Effect.gen(function* () {
  71. const process = yield* AppProcess.Service
  72. const binary = yield* FileSystemRipgrep.Service
  73. const run = <A>(input: {
  74. readonly cwd: string
  75. readonly args: string[]
  76. readonly limit: number
  77. readonly signal?: AbortSignal
  78. readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
  79. readonly pattern?: string
  80. }) => {
  81. const program = Effect.scoped(
  82. Effect.gen(function* () {
  83. const handle = yield* process.spawn(
  84. ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
  85. )
  86. const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
  87. Effect.map((output) => output.buffer.toString("utf8")),
  88. Effect.forkScoped,
  89. )
  90. const rows = yield* Stream.decodeText(handle.stdout).pipe(
  91. Stream.splitLines,
  92. Stream.filter((line) => line.length > 0),
  93. Stream.mapEffect(input.parse),
  94. Stream.filter((row): row is A => row !== undefined),
  95. Stream.take(input.limit + 1),
  96. Stream.runCollect,
  97. Effect.map((chunk) => [...chunk]),
  98. )
  99. const truncated = rows.length > input.limit
  100. if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
  101. const code = yield* handle.exitCode
  102. const stderr = yield* Fiber.join(stderrFiber)
  103. if (input.pattern && code === 2 && isInvalidPattern(stderr)) {
  104. return yield* new InvalidPatternError({ pattern: input.pattern, message: stderr.trim() })
  105. }
  106. if (code !== 0 && code !== 1 && code !== 2) {
  107. return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
  108. }
  109. return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
  110. }),
  111. )
  112. const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
  113. return abortable.pipe(
  114. Effect.mapError((cause) =>
  115. cause instanceof Error || cause instanceof InvalidPatternError
  116. ? cause
  117. : failure("ripgrep execution failed", cause),
  118. ),
  119. )
  120. }
  121. return Service.of({
  122. files: (input) =>
  123. run<string>({
  124. ...input,
  125. args: [
  126. "--no-config",
  127. "--files",
  128. "--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure.
  129. `--glob=${input.pattern}`,
  130. "--glob=!.*",
  131. "--glob=!**/.*",
  132. ".",
  133. ],
  134. parse: (line) => Effect.succeed(line.replace(/^\.\//, "")),
  135. }).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
  136. grep: (input) =>
  137. run<Match>({
  138. ...input,
  139. args: [
  140. "--no-config",
  141. "--json",
  142. "--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure.
  143. "--no-messages",
  144. ...(input.include ? [`--glob=${input.include}`] : []),
  145. "--glob=!.*",
  146. "--glob=!**/.*",
  147. "--",
  148. input.pattern,
  149. input.file ?? ".",
  150. ],
  151. parse: (line) =>
  152. (Buffer.byteLength(line, "utf8") > MAX_RECORD_BYTES
  153. ? Effect.fail(failure(`Ripgrep JSON record exceeded ${MAX_RECORD_BYTES} bytes`))
  154. : Effect.try({
  155. try: () => JSON.parse(line) as unknown,
  156. catch: (cause) => failure("Invalid ripgrep JSON output", cause),
  157. })
  158. ).pipe(
  159. Effect.flatMap((json) => {
  160. if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")
  161. return Effect.succeed(undefined)
  162. return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
  163. Effect.map((match) => ({
  164. ...match.data,
  165. submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
  166. })),
  167. Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
  168. )
  169. }),
  170. ),
  171. }),
  172. })
  173. }),
  174. ).pipe(Layer.provide(FileSystemRipgrep.defaultLayer))