2
0

ripgrep.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. // Ripgrep utility functions
  2. import path from "path"
  3. import { Global } from "../global"
  4. import fs from "fs/promises"
  5. import z from "zod"
  6. import { NamedError } from "@opencode-ai/util/error"
  7. import { lazy } from "../util/lazy"
  8. import { $ } from "bun"
  9. import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
  10. import { Log } from "@/util/log"
  11. export namespace Ripgrep {
  12. const log = Log.create({ service: "ripgrep" })
  13. const Stats = z.object({
  14. elapsed: z.object({
  15. secs: z.number(),
  16. nanos: z.number(),
  17. human: z.string(),
  18. }),
  19. searches: z.number(),
  20. searches_with_match: z.number(),
  21. bytes_searched: z.number(),
  22. bytes_printed: z.number(),
  23. matched_lines: z.number(),
  24. matches: z.number(),
  25. })
  26. const Begin = z.object({
  27. type: z.literal("begin"),
  28. data: z.object({
  29. path: z.object({
  30. text: z.string(),
  31. }),
  32. }),
  33. })
  34. export const Match = z.object({
  35. type: z.literal("match"),
  36. data: z.object({
  37. path: z.object({
  38. text: z.string(),
  39. }),
  40. lines: z.object({
  41. text: z.string(),
  42. }),
  43. line_number: z.number(),
  44. absolute_offset: z.number(),
  45. submatches: z.array(
  46. z.object({
  47. match: z.object({
  48. text: z.string(),
  49. }),
  50. start: z.number(),
  51. end: z.number(),
  52. }),
  53. ),
  54. }),
  55. })
  56. const End = z.object({
  57. type: z.literal("end"),
  58. data: z.object({
  59. path: z.object({
  60. text: z.string(),
  61. }),
  62. binary_offset: z.number().nullable(),
  63. stats: Stats,
  64. }),
  65. })
  66. const Summary = z.object({
  67. type: z.literal("summary"),
  68. data: z.object({
  69. elapsed_total: z.object({
  70. human: z.string(),
  71. nanos: z.number(),
  72. secs: z.number(),
  73. }),
  74. stats: Stats,
  75. }),
  76. })
  77. const Result = z.union([Begin, Match, End, Summary])
  78. export type Result = z.infer<typeof Result>
  79. export type Match = z.infer<typeof Match>
  80. export type Begin = z.infer<typeof Begin>
  81. export type End = z.infer<typeof End>
  82. export type Summary = z.infer<typeof Summary>
  83. const PLATFORM = {
  84. "arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
  85. "arm64-linux": {
  86. platform: "aarch64-unknown-linux-gnu",
  87. extension: "tar.gz",
  88. },
  89. "x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
  90. "x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
  91. "x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
  92. } as const
  93. export const ExtractionFailedError = NamedError.create(
  94. "RipgrepExtractionFailedError",
  95. z.object({
  96. filepath: z.string(),
  97. stderr: z.string(),
  98. }),
  99. )
  100. export const UnsupportedPlatformError = NamedError.create(
  101. "RipgrepUnsupportedPlatformError",
  102. z.object({
  103. platform: z.string(),
  104. }),
  105. )
  106. export const DownloadFailedError = NamedError.create(
  107. "RipgrepDownloadFailedError",
  108. z.object({
  109. url: z.string(),
  110. status: z.number(),
  111. }),
  112. )
  113. const state = lazy(async () => {
  114. let filepath = Bun.which("rg")
  115. if (filepath) return { filepath }
  116. filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : ""))
  117. const file = Bun.file(filepath)
  118. if (!(await file.exists())) {
  119. const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
  120. const config = PLATFORM[platformKey]
  121. if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
  122. const version = "14.1.1"
  123. const filename = `ripgrep-${version}-${config.platform}.${config.extension}`
  124. const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
  125. const response = await fetch(url)
  126. if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
  127. const buffer = await response.arrayBuffer()
  128. const archivePath = path.join(Global.Path.bin, filename)
  129. await Bun.write(archivePath, buffer)
  130. if (config.extension === "tar.gz") {
  131. const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
  132. if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
  133. if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
  134. const proc = Bun.spawn(args, {
  135. cwd: Global.Path.bin,
  136. stderr: "pipe",
  137. stdout: "pipe",
  138. })
  139. await proc.exited
  140. if (proc.exitCode !== 0)
  141. throw new ExtractionFailedError({
  142. filepath,
  143. stderr: await Bun.readableStreamToText(proc.stderr),
  144. })
  145. }
  146. if (config.extension === "zip") {
  147. if (config.extension === "zip") {
  148. const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
  149. const entries = await zipFileReader.getEntries()
  150. let rgEntry: any
  151. for (const entry of entries) {
  152. if (entry.filename.endsWith("rg.exe")) {
  153. rgEntry = entry
  154. break
  155. }
  156. }
  157. if (!rgEntry) {
  158. throw new ExtractionFailedError({
  159. filepath: archivePath,
  160. stderr: "rg.exe not found in zip archive",
  161. })
  162. }
  163. const rgBlob = await rgEntry.getData(new BlobWriter())
  164. if (!rgBlob) {
  165. throw new ExtractionFailedError({
  166. filepath: archivePath,
  167. stderr: "Failed to extract rg.exe from zip archive",
  168. })
  169. }
  170. await Bun.write(filepath, await rgBlob.arrayBuffer())
  171. await zipFileReader.close()
  172. }
  173. }
  174. await fs.unlink(archivePath)
  175. if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
  176. }
  177. return {
  178. filepath,
  179. }
  180. })
  181. export async function filepath() {
  182. const { filepath } = await state()
  183. return filepath
  184. }
  185. export async function* files(input: { cwd: string; glob?: string[] }) {
  186. const args = [await filepath(), "--files", "--follow", "--hidden", "--glob=!.git/*"]
  187. if (input.glob) {
  188. for (const g of input.glob) {
  189. args.push(`--glob=${g}`)
  190. }
  191. }
  192. // Bun.spawn should throw this, but it incorrectly reports that the executable does not exist.
  193. // See https://github.com/oven-sh/bun/issues/24012
  194. if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) {
  195. throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
  196. code: "ENOENT",
  197. errno: -2,
  198. path: input.cwd,
  199. })
  200. }
  201. const proc = Bun.spawn(args, {
  202. cwd: input.cwd,
  203. stdout: "pipe",
  204. stderr: "ignore",
  205. maxBuffer: 1024 * 1024 * 20,
  206. })
  207. const reader = proc.stdout.getReader()
  208. const decoder = new TextDecoder()
  209. let buffer = ""
  210. try {
  211. while (true) {
  212. const { done, value } = await reader.read()
  213. if (done) break
  214. buffer += decoder.decode(value, { stream: true })
  215. const lines = buffer.split("\n")
  216. buffer = lines.pop() || ""
  217. for (const line of lines) {
  218. if (line) yield line
  219. }
  220. }
  221. if (buffer) yield buffer
  222. } finally {
  223. reader.releaseLock()
  224. await proc.exited
  225. }
  226. }
  227. export async function tree(input: { cwd: string; limit?: number }) {
  228. log.info("tree", input)
  229. const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd }))
  230. interface Node {
  231. path: string[]
  232. children: Node[]
  233. }
  234. function getPath(node: Node, parts: string[], create: boolean) {
  235. if (parts.length === 0) return node
  236. let current = node
  237. for (const part of parts) {
  238. let existing = current.children.find((x) => x.path.at(-1) === part)
  239. if (!existing) {
  240. if (!create) return
  241. existing = {
  242. path: current.path.concat(part),
  243. children: [],
  244. }
  245. current.children.push(existing)
  246. }
  247. current = existing
  248. }
  249. return current
  250. }
  251. const root: Node = {
  252. path: [],
  253. children: [],
  254. }
  255. for (const file of files) {
  256. if (file.includes(".opencode")) continue
  257. const parts = file.split(path.sep)
  258. getPath(root, parts, true)
  259. }
  260. function sort(node: Node) {
  261. node.children.sort((a, b) => {
  262. if (!a.children.length && b.children.length) return 1
  263. if (!b.children.length && a.children.length) return -1
  264. return a.path.at(-1)!.localeCompare(b.path.at(-1)!)
  265. })
  266. for (const child of node.children) {
  267. sort(child)
  268. }
  269. }
  270. sort(root)
  271. let current = [root]
  272. const result: Node = {
  273. path: [],
  274. children: [],
  275. }
  276. let processed = 0
  277. const limit = input.limit ?? 50
  278. while (current.length > 0) {
  279. const next = []
  280. for (const node of current) {
  281. if (node.children.length) next.push(...node.children)
  282. }
  283. const max = Math.max(...current.map((x) => x.children.length))
  284. for (let i = 0; i < max && processed < limit; i++) {
  285. for (const node of current) {
  286. const child = node.children[i]
  287. if (!child) continue
  288. getPath(result, child.path, true)
  289. processed++
  290. if (processed >= limit) break
  291. }
  292. }
  293. if (processed >= limit) {
  294. for (const node of [...current, ...next]) {
  295. const compare = getPath(result, node.path, false)
  296. if (!compare) continue
  297. if (compare?.children.length !== node.children.length) {
  298. const diff = node.children.length - compare.children.length
  299. compare.children.push({
  300. path: compare.path.concat(`[${diff} truncated]`),
  301. children: [],
  302. })
  303. }
  304. }
  305. break
  306. }
  307. current = next
  308. }
  309. const lines: string[] = []
  310. function render(node: Node, depth: number) {
  311. const indent = "\t".repeat(depth)
  312. lines.push(indent + node.path.at(-1) + (node.children.length ? "/" : ""))
  313. for (const child of node.children) {
  314. render(child, depth + 1)
  315. }
  316. }
  317. result.children.map((x) => render(x, 0))
  318. return lines.join("\n")
  319. }
  320. export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) {
  321. const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
  322. if (input.glob) {
  323. for (const g of input.glob) {
  324. args.push(`--glob=${g}`)
  325. }
  326. }
  327. if (input.limit) {
  328. args.push(`--max-count=${input.limit}`)
  329. }
  330. args.push("--")
  331. args.push(input.pattern)
  332. const command = args.join(" ")
  333. const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
  334. if (result.exitCode !== 0) {
  335. return []
  336. }
  337. const lines = result.text().trim().split("\n").filter(Boolean)
  338. // Parse JSON lines from ripgrep output
  339. return lines
  340. .map((line) => JSON.parse(line))
  341. .map((parsed) => Result.parse(parsed))
  342. .filter((r) => r.type === "match")
  343. .map((r) => r.data)
  344. }
  345. }