ripgrep.ts 10 KB

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