ripgrep.test.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect } from "effect"
  3. import * as Stream from "effect/Stream"
  4. import fs from "fs/promises"
  5. import path from "path"
  6. import { tmpdir } from "../fixture/fixture"
  7. import { Ripgrep } from "../../src/file/ripgrep"
  8. async function seed(dir: string, count: number, size = 16) {
  9. const txt = "a".repeat(size)
  10. await Promise.all(Array.from({ length: count }, (_, i) => Bun.write(path.join(dir, `file-${i}.txt`), `${txt}${i}\n`)))
  11. }
  12. function env(name: string, value: string | undefined) {
  13. const prev = process.env[name]
  14. if (value === undefined) delete process.env[name]
  15. else process.env[name] = value
  16. return () => {
  17. if (prev === undefined) delete process.env[name]
  18. else process.env[name] = prev
  19. }
  20. }
  21. describe("file.ripgrep", () => {
  22. test("defaults to include hidden", async () => {
  23. await using tmp = await tmpdir({
  24. init: async (dir) => {
  25. await Bun.write(path.join(dir, "visible.txt"), "hello")
  26. await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
  27. await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
  28. },
  29. })
  30. const files = await Array.fromAsync(await Ripgrep.files({ cwd: tmp.path }))
  31. expect(files.includes("visible.txt")).toBe(true)
  32. expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
  33. })
  34. test("hidden false excludes hidden", async () => {
  35. await using tmp = await tmpdir({
  36. init: async (dir) => {
  37. await Bun.write(path.join(dir, "visible.txt"), "hello")
  38. await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
  39. await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
  40. },
  41. })
  42. const files = await Array.fromAsync(await Ripgrep.files({ cwd: tmp.path, hidden: false }))
  43. expect(files.includes("visible.txt")).toBe(true)
  44. expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
  45. })
  46. // kilocode_change start - .kilo directory should also be skipped in tree()
  47. test("tree skips .kilo directory files", async () => {
  48. await using tmp = await tmpdir({
  49. init: async (dir) => {
  50. await Bun.write(path.join(dir, "src", "main.ts"), "export {}")
  51. await fs.mkdir(path.join(dir, ".kilo"), { recursive: true })
  52. await Bun.write(path.join(dir, ".kilo", "config.json"), "{}")
  53. },
  54. })
  55. const result = await Ripgrep.tree({ cwd: tmp.path })
  56. expect(result).not.toContain(".kilo")
  57. expect(result).toContain("src")
  58. })
  59. // kilocode_change end
  60. test("search returns empty when nothing matches", async () => {
  61. await using tmp = await tmpdir({
  62. init: async (dir) => {
  63. await Bun.write(path.join(dir, "match.ts"), "const value = 'other'\n")
  64. },
  65. })
  66. const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" })
  67. expect(result.partial).toBe(false)
  68. expect(result.items).toEqual([])
  69. })
  70. test("search returns match metadata with normalized path", async () => {
  71. await using tmp = await tmpdir({
  72. init: async (dir) => {
  73. await fs.mkdir(path.join(dir, "src"), { recursive: true })
  74. await Bun.write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
  75. },
  76. })
  77. const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" })
  78. expect(result.partial).toBe(false)
  79. expect(result.items).toHaveLength(1)
  80. expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts"))
  81. expect(result.items[0]?.line_number).toBe(1)
  82. expect(result.items[0]?.lines.text).toContain("needle")
  83. })
  84. test("files returns empty when glob matches no files in worker mode", async () => {
  85. await using tmp = await tmpdir({
  86. init: async (dir) => {
  87. await fs.mkdir(path.join(dir, "packages", "console"), { recursive: true })
  88. await Bun.write(path.join(dir, "packages", "console", "package.json"), "{}")
  89. },
  90. })
  91. const ctl = new AbortController()
  92. const files = await Array.fromAsync(
  93. await Ripgrep.files({
  94. cwd: tmp.path,
  95. glob: ["packages/*"],
  96. signal: ctl.signal,
  97. }),
  98. )
  99. expect(files).toEqual([])
  100. })
  101. test("ignores RIPGREP_CONFIG_PATH in direct mode", async () => {
  102. await using tmp = await tmpdir({
  103. init: async (dir) => {
  104. await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
  105. },
  106. })
  107. const restore = env("RIPGREP_CONFIG_PATH", path.join(tmp.path, "missing-ripgreprc"))
  108. try {
  109. const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" })
  110. expect(result.items).toHaveLength(1)
  111. } finally {
  112. restore()
  113. }
  114. })
  115. test("ignores RIPGREP_CONFIG_PATH in worker mode", async () => {
  116. await using tmp = await tmpdir({
  117. init: async (dir) => {
  118. await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
  119. },
  120. })
  121. const restore = env("RIPGREP_CONFIG_PATH", path.join(tmp.path, "missing-ripgreprc"))
  122. try {
  123. const ctl = new AbortController()
  124. const result = await Ripgrep.search({
  125. cwd: tmp.path,
  126. pattern: "needle",
  127. signal: ctl.signal,
  128. })
  129. expect(result.items).toHaveLength(1)
  130. } finally {
  131. restore()
  132. }
  133. })
  134. test("aborts files scan in worker mode", async () => {
  135. await using tmp = await tmpdir({
  136. init: async (dir) => {
  137. await seed(dir, 4000)
  138. },
  139. })
  140. const ctl = new AbortController()
  141. const iter = await Ripgrep.files({ cwd: tmp.path, signal: ctl.signal })
  142. const pending = Array.fromAsync(iter)
  143. setTimeout(() => ctl.abort(), 0)
  144. const err = await pending.catch((err) => err)
  145. expect(err).toBeInstanceOf(Error)
  146. expect(err.name).toBe("AbortError")
  147. }, 15_000)
  148. test("aborts search in worker mode", async () => {
  149. await using tmp = await tmpdir({
  150. init: async (dir) => {
  151. await seed(dir, 512, 64 * 1024)
  152. },
  153. })
  154. const ctl = new AbortController()
  155. const pending = Ripgrep.search({ cwd: tmp.path, pattern: "needle", signal: ctl.signal })
  156. setTimeout(() => ctl.abort(), 0)
  157. const err = await pending.catch((err) => err)
  158. expect(err).toBeInstanceOf(Error)
  159. expect(err.name).toBe("AbortError")
  160. }, 15_000)
  161. })
  162. describe("Ripgrep.Service", () => {
  163. test("search returns matched rows", async () => {
  164. await using tmp = await tmpdir({
  165. init: async (dir) => {
  166. await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
  167. await Bun.write(path.join(dir, "skip.txt"), "const value = 'other'\n")
  168. },
  169. })
  170. const result = await Effect.gen(function* () {
  171. const rg = yield* Ripgrep.Service
  172. return yield* rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] })
  173. }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
  174. expect(result.partial).toBe(false)
  175. expect(result.items).toHaveLength(1)
  176. expect(result.items[0]?.path.text).toContain("match.ts")
  177. expect(result.items[0]?.lines.text).toContain("needle")
  178. })
  179. test("search supports explicit file targets", async () => {
  180. await using tmp = await tmpdir({
  181. init: async (dir) => {
  182. await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
  183. await Bun.write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
  184. },
  185. })
  186. const file = path.join(tmp.path, "match.ts")
  187. const result = await Effect.gen(function* () {
  188. const rg = yield* Ripgrep.Service
  189. return yield* rg.search({ cwd: tmp.path, pattern: "needle", file: [file] })
  190. }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
  191. expect(result.partial).toBe(false)
  192. expect(result.items).toHaveLength(1)
  193. expect(result.items[0]?.path.text).toBe(file)
  194. })
  195. test("files returns stream of filenames", async () => {
  196. await using tmp = await tmpdir({
  197. init: async (dir) => {
  198. await Bun.write(path.join(dir, "a.txt"), "hello")
  199. await Bun.write(path.join(dir, "b.txt"), "world")
  200. },
  201. })
  202. const files = await Effect.gen(function* () {
  203. const rg = yield* Ripgrep.Service
  204. return yield* rg.files({ cwd: tmp.path }).pipe(
  205. Stream.runCollect,
  206. Effect.map((chunk) => [...chunk].sort()),
  207. )
  208. }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
  209. expect(files).toEqual(["a.txt", "b.txt"])
  210. })
  211. test("files respects glob filter", async () => {
  212. await using tmp = await tmpdir({
  213. init: async (dir) => {
  214. await Bun.write(path.join(dir, "keep.ts"), "yes")
  215. await Bun.write(path.join(dir, "skip.txt"), "no")
  216. },
  217. })
  218. const files = await Effect.gen(function* () {
  219. const rg = yield* Ripgrep.Service
  220. return yield* rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe(
  221. Stream.runCollect,
  222. Effect.map((chunk) => [...chunk]),
  223. )
  224. }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
  225. expect(files).toEqual(["keep.ts"])
  226. })
  227. test("files dies on nonexistent directory", async () => {
  228. const exit = await Effect.gen(function* () {
  229. const rg = yield* Ripgrep.Service
  230. return yield* rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect)
  231. }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit)
  232. expect(exit._tag).toBe("Failure")
  233. })
  234. })