ripgrep.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. import path from "path"
  2. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  3. import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
  4. import type { PlatformError } from "effect/PlatformError"
  5. import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
  6. import { ChildProcess } from "effect/unstable/process"
  7. import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
  8. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  9. import { Global } from "@opencode-ai/core/global"
  10. import * as Log from "@opencode-ai/core/util/log"
  11. import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
  12. import { which } from "@/util/which"
  13. import { zod } from "@/util/effect-zod"
  14. import { withStatics } from "@/util/schema"
  15. const log = Log.create({ service: "ripgrep" })
  16. const VERSION = "15.1.0"
  17. const PLATFORM = {
  18. "arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
  19. "arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
  20. "x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
  21. "x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
  22. "arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
  23. "ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" },
  24. "x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
  25. } as const
  26. const TimeStats = Schema.Struct({
  27. secs: Schema.Number,
  28. nanos: Schema.Number,
  29. human: Schema.String,
  30. })
  31. const Stats = Schema.Struct({
  32. elapsed: TimeStats,
  33. searches: Schema.Number,
  34. searches_with_match: Schema.Number,
  35. bytes_searched: Schema.Number,
  36. bytes_printed: Schema.Number,
  37. matched_lines: Schema.Number,
  38. matches: Schema.Number,
  39. })
  40. const PathText = Schema.Struct({
  41. text: Schema.String,
  42. })
  43. const Begin = Schema.Struct({
  44. type: Schema.Literal("begin"),
  45. data: Schema.Struct({
  46. path: PathText,
  47. }),
  48. })
  49. export const SearchMatch = Schema.Struct({
  50. path: PathText,
  51. lines: Schema.Struct({
  52. text: Schema.String,
  53. }),
  54. line_number: Schema.Number,
  55. absolute_offset: Schema.Number,
  56. submatches: Schema.Array(
  57. Schema.Struct({
  58. match: Schema.Struct({
  59. text: Schema.String,
  60. }),
  61. start: Schema.Number,
  62. end: Schema.Number,
  63. }),
  64. ),
  65. }).pipe(withStatics((s) => ({ zod: zod(s) })))
  66. export const Match = Schema.Struct({
  67. type: Schema.Literal("match"),
  68. data: SearchMatch,
  69. })
  70. const End = Schema.Struct({
  71. type: Schema.Literal("end"),
  72. data: Schema.Struct({
  73. path: PathText,
  74. binary_offset: Schema.NullOr(Schema.Number),
  75. stats: Stats,
  76. }),
  77. })
  78. const Summary = Schema.Struct({
  79. type: Schema.Literal("summary"),
  80. data: Schema.Struct({
  81. elapsed_total: TimeStats,
  82. stats: Stats,
  83. }),
  84. })
  85. const Result = Schema.Union([Begin, Match, End, Summary])
  86. const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result))
  87. export type Result = Schema.Schema.Type<typeof Result>
  88. export type Match = Schema.Schema.Type<typeof Match>
  89. export type Item = Match["data"]
  90. export type Begin = Schema.Schema.Type<typeof Begin>
  91. export type End = Schema.Schema.Type<typeof End>
  92. export type Summary = Schema.Schema.Type<typeof Summary>
  93. export type Row = Match["data"]
  94. export interface SearchResult {
  95. items: Item[]
  96. partial: boolean
  97. }
  98. export interface FilesInput {
  99. cwd: string
  100. glob?: string[]
  101. hidden?: boolean
  102. follow?: boolean
  103. maxDepth?: number
  104. signal?: AbortSignal
  105. }
  106. export interface SearchInput {
  107. cwd: string
  108. pattern: string
  109. glob?: string[]
  110. limit?: number
  111. follow?: boolean
  112. file?: string[]
  113. signal?: AbortSignal
  114. }
  115. export interface TreeInput {
  116. cwd: string
  117. limit?: number
  118. signal?: AbortSignal
  119. }
  120. export interface Interface {
  121. readonly files: (input: FilesInput) => Stream.Stream<string, PlatformError | Error>
  122. readonly tree: (input: TreeInput) => Effect.Effect<string, PlatformError | Error>
  123. readonly search: (input: SearchInput) => Effect.Effect<SearchResult, PlatformError | Error>
  124. }
  125. export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
  126. function env() {
  127. const env = sanitizedProcessEnv()
  128. delete env.RIPGREP_CONFIG_PATH
  129. return env
  130. }
  131. function aborted(signal?: AbortSignal) {
  132. const err = signal?.reason
  133. if (err instanceof Error) return err
  134. const out = new Error("Aborted")
  135. out.name = "AbortError"
  136. return out
  137. }
  138. function waitForAbort(signal?: AbortSignal) {
  139. if (!signal) return Effect.never
  140. if (signal.aborted) return Effect.fail(aborted(signal))
  141. return Effect.callback<never, Error>((resume) => {
  142. const onabort = () => resume(Effect.fail(aborted(signal)))
  143. signal.addEventListener("abort", onabort, { once: true })
  144. return Effect.sync(() => signal.removeEventListener("abort", onabort))
  145. })
  146. }
  147. function error(stderr: string, code: number) {
  148. const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`)
  149. err.name = "RipgrepError"
  150. return err
  151. }
  152. function clean(file: string) {
  153. return path.normalize(file.replace(/^\.[\\/]/, ""))
  154. }
  155. function row(data: Row): Row {
  156. return {
  157. ...data,
  158. path: {
  159. ...data.path,
  160. text: clean(data.path.text),
  161. },
  162. }
  163. }
  164. function parse(line: string) {
  165. return decodeResult(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause })))
  166. }
  167. function fail(queue: Queue.Queue<string, PlatformError | Error | Cause.Done>, err: PlatformError | Error) {
  168. Queue.failCauseUnsafe(queue, Cause.fail(err))
  169. }
  170. function filesArgs(input: FilesInput) {
  171. const args = ["--no-config", "--files", "--glob=!.git/*"]
  172. if (input.follow) args.push("--follow")
  173. if (input.hidden !== false) args.push("--hidden")
  174. if (input.hidden === false) args.push("--glob=!.*")
  175. if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
  176. if (input.glob) {
  177. for (const glob of input.glob) args.push(`--glob=${glob}`)
  178. }
  179. args.push(".")
  180. return args
  181. }
  182. function searchArgs(input: SearchInput) {
  183. const args = ["--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"]
  184. if (input.follow) args.push("--follow")
  185. if (input.glob) {
  186. for (const glob of input.glob) args.push(`--glob=${glob}`)
  187. }
  188. if (input.limit) args.push(`--max-count=${input.limit}`)
  189. args.push("--", input.pattern, ...(input.file ?? ["."]))
  190. return args
  191. }
  192. function raceAbort<A, E, R>(effect: Effect.Effect<A, E, R>, signal?: AbortSignal) {
  193. return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect
  194. }
  195. export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner | HttpClient.HttpClient> =
  196. Layer.effect(
  197. Service,
  198. Effect.gen(function* () {
  199. const fs = yield* AppFileSystem.Service
  200. const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
  201. const spawner = yield* ChildProcessSpawner
  202. const run = Effect.fnUntraced(function* (command: string, args: string[], opts?: { cwd?: string }) {
  203. const handle = yield* spawner.spawn(
  204. ChildProcess.make(command, args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
  205. )
  206. const [stdout, stderr, code] = yield* Effect.all(
  207. [
  208. Stream.mkString(Stream.decodeText(handle.stdout)),
  209. Stream.mkString(Stream.decodeText(handle.stderr)),
  210. handle.exitCode,
  211. ],
  212. { concurrency: "unbounded" },
  213. )
  214. return { stdout, stderr, code }
  215. }, Effect.scoped)
  216. const extract = Effect.fnUntraced(function* (
  217. archive: string,
  218. config: (typeof PLATFORM)[keyof typeof PLATFORM],
  219. target: string,
  220. ) {
  221. const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
  222. if (config.extension === "zip") {
  223. const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
  224. const result = yield* run(shell, [
  225. "-NoProfile",
  226. "-NonInteractive",
  227. "-Command",
  228. `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`,
  229. ])
  230. if (result.code !== 0) {
  231. return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
  232. }
  233. }
  234. if (config.extension === "tar.gz") {
  235. const result = yield* run("tar", ["-xzf", archive, "-C", dir])
  236. if (result.code !== 0) {
  237. return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
  238. }
  239. }
  240. const extracted = path.join(
  241. dir,
  242. `ripgrep-${VERSION}-${config.platform}`,
  243. process.platform === "win32" ? "rg.exe" : "rg",
  244. )
  245. if (!(yield* fs.isFile(extracted))) {
  246. return yield* Effect.fail(new Error(`ripgrep archive did not contain executable: ${extracted}`))
  247. }
  248. yield* fs.copyFile(extracted, target)
  249. if (process.platform === "win32") return
  250. yield* fs.chmod(target, 0o755)
  251. }, Effect.scoped)
  252. const filepath = yield* Effect.cached(
  253. Effect.gen(function* () {
  254. const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
  255. if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
  256. const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
  257. if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
  258. const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
  259. const config = PLATFORM[platformKey]
  260. if (!config) {
  261. return yield* Effect.fail(new Error(`unsupported platform for ripgrep: ${platformKey}`))
  262. }
  263. const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
  264. const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
  265. const archive = path.join(Global.Path.bin, filename)
  266. log.info("downloading ripgrep", { url })
  267. yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
  268. const bytes = yield* HttpClientRequest.get(url).pipe(
  269. http.execute,
  270. Effect.flatMap((response) => response.arrayBuffer),
  271. Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
  272. )
  273. if (bytes.byteLength === 0) {
  274. return yield* Effect.fail(new Error(`failed to download ripgrep from ${url}`))
  275. }
  276. yield* fs.writeWithDirs(archive, new Uint8Array(bytes))
  277. yield* extract(archive, config, target)
  278. yield* fs.remove(archive, { force: true }).pipe(Effect.ignore)
  279. return target
  280. }),
  281. )
  282. const check = Effect.fnUntraced(function* (cwd: string) {
  283. if (yield* fs.isDir(cwd).pipe(Effect.orDie)) return
  284. return yield* Effect.fail(
  285. Object.assign(new Error(`No such file or directory: '${cwd}'`), {
  286. code: "ENOENT",
  287. errno: -2,
  288. path: cwd,
  289. }),
  290. )
  291. })
  292. const command = Effect.fnUntraced(function* (cwd: string, args: string[]) {
  293. const binary = yield* filepath
  294. return ChildProcess.make(binary, args, {
  295. cwd,
  296. env: env(),
  297. extendEnv: true,
  298. stdin: "ignore",
  299. })
  300. })
  301. const files: Interface["files"] = (input) =>
  302. Stream.callback<string, PlatformError | Error>((queue) =>
  303. Effect.gen(function* () {
  304. yield* Effect.forkScoped(
  305. Effect.gen(function* () {
  306. yield* check(input.cwd)
  307. const handle = yield* spawner.spawn(yield* command(input.cwd, filesArgs(input)))
  308. const stderr = yield* Stream.mkString(Stream.decodeText(handle.stderr)).pipe(Effect.forkScoped)
  309. const stdout = yield* Stream.decodeText(handle.stdout).pipe(
  310. Stream.splitLines,
  311. Stream.filter((line) => line.length > 0),
  312. Stream.runForEach((line) => Effect.sync(() => Queue.offerUnsafe(queue, clean(line)))),
  313. Effect.forkScoped,
  314. )
  315. const code = yield* raceAbort(handle.exitCode, input.signal)
  316. yield* Fiber.join(stdout)
  317. if (code === 0 || code === 1) {
  318. Queue.endUnsafe(queue)
  319. return
  320. }
  321. fail(queue, error(yield* Fiber.join(stderr), code))
  322. }).pipe(
  323. Effect.catch((err) =>
  324. Effect.sync(() => {
  325. fail(queue, err)
  326. }),
  327. ),
  328. ),
  329. )
  330. }),
  331. )
  332. const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
  333. yield* check(input.cwd)
  334. const program = Effect.scoped(
  335. Effect.gen(function* () {
  336. const handle = yield* spawner.spawn(yield* command(input.cwd, searchArgs(input)))
  337. const [items, stderr, code] = yield* Effect.all(
  338. [
  339. Stream.decodeText(handle.stdout).pipe(
  340. Stream.splitLines,
  341. Stream.filter((line) => line.length > 0),
  342. Stream.mapEffect(parse),
  343. Stream.filter((item): item is Match => item.type === "match"),
  344. Stream.map((item) => row(item.data)),
  345. Stream.runCollect,
  346. Effect.map((chunk) => [...chunk]),
  347. ),
  348. Stream.mkString(Stream.decodeText(handle.stderr)),
  349. handle.exitCode,
  350. ],
  351. { concurrency: "unbounded" },
  352. )
  353. if (code !== 0 && code !== 1 && code !== 2) {
  354. return yield* Effect.fail(error(stderr, code))
  355. }
  356. return {
  357. items: code === 1 ? [] : items,
  358. partial: code === 2,
  359. }
  360. }),
  361. )
  362. return yield* raceAbort(program, input.signal)
  363. })
  364. const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
  365. log.info("tree", input)
  366. const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
  367. interface Node {
  368. name: string
  369. children: Map<string, Node>
  370. }
  371. function child(node: Node, name: string) {
  372. const item = node.children.get(name)
  373. if (item) return item
  374. const next = { name, children: new Map() }
  375. node.children.set(name, next)
  376. return next
  377. }
  378. function count(node: Node): number {
  379. return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
  380. }
  381. const root: Node = { name: "", children: new Map() }
  382. for (const file of list) {
  383. if (file.includes(".opencode")) continue
  384. const parts = file.split(path.sep)
  385. if (parts.length < 2) continue
  386. let node = root
  387. for (const part of parts.slice(0, -1)) {
  388. node = child(node, part)
  389. }
  390. }
  391. const total = count(root)
  392. const limit = input.limit ?? total
  393. const lines: string[] = []
  394. const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values())
  395. .sort((a, b) => a.name.localeCompare(b.name))
  396. .map((node) => ({ node, path: node.name }))
  397. let used = 0
  398. for (let i = 0; i < queue.length && used < limit; i++) {
  399. const item = queue[i]
  400. lines.push(item.path)
  401. used++
  402. queue.push(
  403. ...Array.from(item.node.children.values())
  404. .sort((a, b) => a.name.localeCompare(b.name))
  405. .map((node) => ({ node, path: `${item.path}/${node.name}` })),
  406. )
  407. }
  408. if (total > used) lines.push(`[${total - used} truncated]`)
  409. return lines.join("\n")
  410. })
  411. return Service.of({ files, tree, search })
  412. }),
  413. )
  414. export const defaultLayer = layer.pipe(
  415. Layer.provide(FetchHttpClient.layer),
  416. Layer.provide(AppFileSystem.defaultLayer),
  417. Layer.provide(CrossSpawnSpawner.defaultLayer),
  418. )
  419. export * as Ripgrep from "./ripgrep"