ripgrep.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  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 path from "path"
  5. import { LayerNode } from "./effect/layer-node"
  6. import { Entry, Match } from "./filesystem/schema"
  7. import { FSUtil } from "./fs-util"
  8. import { AppProcess, collectStream, waitForAbort } from "./process"
  9. import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
  10. import { RipgrepBinary } from "./ripgrep/binary"
  11. /**
  12. * Small core-owned ripgrep execution adapter. It deliberately exposes raw
  13. * process-oriented rows, not model text or permission behavior. Search maps
  14. * these rows into filesystem results; leaf tools own
  15. * presentation and permission prompts.
  16. */
  17. const ERROR_BYTES = 8 * 1024
  18. const MAX_RECORD_BYTES = 64 * 1024
  19. const MAX_SUBMATCHES = 100
  20. const RawMatch = Schema.Struct({
  21. type: Schema.Literal("match"),
  22. data: Schema.Struct({
  23. path: Schema.Struct({ text: Schema.String }),
  24. lines: Schema.Struct({ text: Schema.String }),
  25. line_number: PositiveInt,
  26. absolute_offset: NonNegativeInt,
  27. submatches: Schema.Array(
  28. Schema.Struct({
  29. match: Schema.Struct({ text: Schema.String }),
  30. start: NonNegativeInt,
  31. end: NonNegativeInt,
  32. }),
  33. ),
  34. }),
  35. })
  36. type RawMatchData = (typeof RawMatch.Type)["data"]
  37. export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
  38. message: Schema.String,
  39. cause: Schema.optional(Schema.Defect()),
  40. }) {}
  41. export class InvalidPatternError extends Schema.TaggedErrorClass<InvalidPatternError>()("Ripgrep.InvalidPatternError", {
  42. pattern: Schema.String,
  43. message: Schema.String,
  44. }) {}
  45. export interface FindInput {
  46. readonly cwd: string
  47. readonly pattern: string
  48. readonly limit: number
  49. readonly hidden?: boolean
  50. readonly follow?: boolean
  51. readonly signal?: AbortSignal
  52. readonly onEntry?: (entry: Entry) => Effect.Effect<void>
  53. }
  54. export interface GlobInput {
  55. readonly cwd: string
  56. readonly pattern: string
  57. readonly limit: number
  58. readonly hidden?: boolean
  59. readonly follow?: boolean
  60. readonly signal?: AbortSignal
  61. }
  62. export interface GrepInput {
  63. readonly cwd: string
  64. readonly pattern: string
  65. readonly file?: string
  66. readonly include?: string
  67. readonly limit: number
  68. readonly signal?: AbortSignal
  69. }
  70. export interface Interface {
  71. readonly find: (input: FindInput) => Effect.Effect<readonly Entry[], Error>
  72. readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[], Error>
  73. readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[], Error | InvalidPatternError>
  74. }
  75. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Ripgrep") {}
  76. const failure = (message: string, cause?: unknown) => new Error({ message, cause })
  77. const isInvalidPattern = (stderr: string) =>
  78. stderr.includes("regex parse error") || stderr.includes("error parsing regex")
  79. export const layer = Layer.effect(
  80. Service,
  81. Effect.gen(function* () {
  82. const process = yield* AppProcess.Service
  83. const binary = yield* RipgrepBinary.Service
  84. const run = <A>(input: {
  85. readonly cwd: string
  86. readonly args: string[]
  87. readonly limit: number
  88. readonly signal?: AbortSignal
  89. readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
  90. readonly pattern?: string
  91. readonly onItem?: (item: A) => Effect.Effect<void>
  92. }) => {
  93. const program = Effect.scoped(
  94. Effect.gen(function* () {
  95. const handle = yield* process.spawn(
  96. ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
  97. )
  98. const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
  99. Effect.map((output) => output.buffer.toString("utf8")),
  100. Effect.forkScoped,
  101. )
  102. let observed = 0
  103. const rows = yield* Stream.decodeText(handle.stdout).pipe(
  104. Stream.splitLines,
  105. Stream.filter((line) => line.length > 0),
  106. Stream.mapEffect(input.parse),
  107. Stream.filter((row): row is A => row !== undefined),
  108. Stream.tap((row) => {
  109. if (!input.onItem || observed++ >= input.limit) return Effect.void
  110. return input.onItem(row)
  111. }),
  112. Stream.take(input.limit + 1),
  113. Stream.runCollect,
  114. Effect.map((chunk) => [...chunk]),
  115. )
  116. const truncated = rows.length > input.limit
  117. if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
  118. const code = yield* handle.exitCode
  119. const stderr = yield* Fiber.join(stderrFiber)
  120. if (input.pattern && code === 2 && isInvalidPattern(stderr)) {
  121. return yield* new InvalidPatternError({ pattern: input.pattern, message: stderr.trim() })
  122. }
  123. if (code !== 0 && code !== 1 && code !== 2) {
  124. return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
  125. }
  126. return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
  127. }),
  128. )
  129. const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
  130. return abortable.pipe(
  131. Effect.mapError((cause) =>
  132. cause instanceof Error || cause instanceof InvalidPatternError
  133. ? cause
  134. : failure("ripgrep execution failed", cause),
  135. ),
  136. )
  137. }
  138. return Service.of({
  139. glob: (input) =>
  140. run<string>({
  141. cwd: input.cwd,
  142. limit: input.limit,
  143. signal: input.signal,
  144. args: [
  145. "--no-config",
  146. "--files",
  147. ...(input.hidden ? ["--hidden"] : []),
  148. ...(input.follow ? ["--follow"] : []),
  149. `--glob=${input.pattern}`,
  150. "--glob=!**/.git/**",
  151. ".",
  152. ],
  153. parse: (line) =>
  154. Effect.succeed(
  155. line
  156. .replace(/^(?:\.[\\/])+/u, "")
  157. .replace(/^[\\/]+/u, "")
  158. .replaceAll("\\", "/"),
  159. ),
  160. }).pipe(
  161. Effect.map((result) =>
  162. result.items.map((relative) => {
  163. const absolute = path.resolve(input.cwd, relative)
  164. return new Entry({
  165. path: RelativePath.make(relative),
  166. type: "file",
  167. mime: FSUtil.mimeType(absolute),
  168. })
  169. }),
  170. ),
  171. Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
  172. ),
  173. find: (input) =>
  174. run<Entry>({
  175. cwd: input.cwd,
  176. limit: input.limit,
  177. signal: input.signal,
  178. args: [
  179. "--no-config",
  180. "--files",
  181. ...(input.hidden ? ["--hidden"] : []),
  182. ...(input.follow ? ["--follow"] : []),
  183. ...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]),
  184. "--glob=!**/.git/**",
  185. ".",
  186. ],
  187. parse: (line) => {
  188. const relative = line
  189. .replace(/^(?:\.[\\/])+/u, "")
  190. .replace(/^[\\/]+/u, "")
  191. .replaceAll("\\", "/")
  192. return Effect.succeed(
  193. new Entry({
  194. path: RelativePath.make(relative),
  195. type: "file",
  196. mime: FSUtil.mimeType(path.resolve(input.cwd, relative)),
  197. }),
  198. )
  199. },
  200. onItem: input.onEntry,
  201. }).pipe(
  202. Effect.map((result) => result.items),
  203. Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
  204. ),
  205. grep: (input) =>
  206. run<RawMatchData>({
  207. ...input,
  208. args: [
  209. "--no-config",
  210. "--json",
  211. "--hidden",
  212. "--no-messages",
  213. ...(input.include ? [`--glob=${input.include}`] : []),
  214. "--glob=!**/.git/**",
  215. "--",
  216. input.pattern,
  217. input.file ?? ".",
  218. ],
  219. parse: (line) =>
  220. (Buffer.byteLength(line, "utf8") > MAX_RECORD_BYTES
  221. ? Effect.fail(failure(`Ripgrep JSON record exceeded ${MAX_RECORD_BYTES} bytes`))
  222. : Effect.try({
  223. try: () => JSON.parse(line) as unknown,
  224. catch: (cause) => failure("Invalid ripgrep JSON output", cause),
  225. })
  226. ).pipe(
  227. Effect.flatMap((json) => {
  228. if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")
  229. return Effect.succeed(undefined)
  230. return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
  231. Effect.map((match) => ({
  232. ...match.data,
  233. path: { text: match.data.path.text.replace(/^\.[\\/]/, "") },
  234. submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
  235. })),
  236. Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
  237. )
  238. }),
  239. ),
  240. }).pipe(
  241. Effect.map((result) =>
  242. result.items.map((match) => {
  243. const relative = match.path.text
  244. .replace(/^(?:\.[\\/])+/u, "")
  245. .replace(/^[\\/]+/u, "")
  246. .replaceAll("\\", "/")
  247. const absolute = path.resolve(input.cwd, relative)
  248. return new Match({
  249. entry: new Entry({
  250. path: RelativePath.make(relative),
  251. type: "file",
  252. mime: FSUtil.mimeType(absolute),
  253. }),
  254. line: match.line_number,
  255. offset: match.absolute_offset,
  256. text: match.lines.text.length > 2_000 ? match.lines.text.slice(0, 2_000) + "..." : match.lines.text,
  257. submatches: match.submatches.map((submatch) => ({
  258. text: submatch.match.text,
  259. start: submatch.start,
  260. end: submatch.end,
  261. })),
  262. })
  263. }),
  264. ),
  265. ),
  266. })
  267. }),
  268. )
  269. export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)))
  270. export const node = LayerNode.make(layer, [RipgrepBinary.node, AppProcess.node])