ripgrep.ts 9.8 KB

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