ripgrep.test.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import { describe, expect } from "bun:test"
  2. import { Effect } from "effect"
  3. import * as Stream from "effect/Stream"
  4. import fs from "fs/promises"
  5. import os from "os"
  6. import path from "path"
  7. import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
  8. import { testEffect } from "../lib/effect"
  9. const it = testEffect(Ripgrep.defaultLayer)
  10. const tmpdir = (init?: (dir: string) => Effect.Effect<void>) =>
  11. Effect.acquireRelease(
  12. Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))),
  13. (dir) =>
  14. Effect.promise(() =>
  15. fs.rm(dir, {
  16. recursive: true,
  17. force: true,
  18. maxRetries: 5,
  19. retryDelay: 100,
  20. }),
  21. ).pipe(Effect.ignore),
  22. ).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
  23. const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
  24. const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
  25. const collectFiles = (input: Ripgrep.FilesInput) =>
  26. Ripgrep.Service.use((rg) =>
  27. rg.files(input).pipe(
  28. Stream.runCollect,
  29. Effect.map((c) => [...c]),
  30. ),
  31. )
  32. const withRipgrepConfig = <A, E, R>(value: string, effect: Effect.Effect<A, E, R>) =>
  33. Effect.acquireUseRelease(
  34. Effect.sync(() => {
  35. const prev = process.env["RIPGREP_CONFIG_PATH"]
  36. process.env["RIPGREP_CONFIG_PATH"] = value
  37. return prev
  38. }),
  39. () => effect,
  40. (prev) =>
  41. Effect.sync(() => {
  42. if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
  43. else process.env["RIPGREP_CONFIG_PATH"] = prev
  44. }),
  45. )
  46. describe("file.ripgrep", () => {
  47. it.live("exposes a cached managed executable filepath", () =>
  48. Effect.gen(function* () {
  49. const ripgrep = yield* Ripgrep.Service
  50. const first = yield* ripgrep.filepath
  51. const second = yield* ripgrep.filepath
  52. expect(first).toBe(second)
  53. expect((yield* Effect.promise(() => fs.stat(first))).isFile()).toBe(true)
  54. }),
  55. )
  56. it.live("defaults to include hidden", () =>
  57. Effect.gen(function* () {
  58. const dir = yield* tmpdir((dir) =>
  59. Effect.gen(function* () {
  60. yield* write(path.join(dir, "visible.txt"), "hello")
  61. yield* mkdir(path.join(dir, ".opencode"))
  62. yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
  63. }),
  64. )
  65. const files = yield* collectFiles({ cwd: dir })
  66. expect(files.includes("visible.txt")).toBe(true)
  67. expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
  68. }),
  69. )
  70. it.live("hidden false excludes hidden", () =>
  71. Effect.gen(function* () {
  72. const dir = yield* tmpdir((dir) =>
  73. Effect.gen(function* () {
  74. yield* write(path.join(dir, "visible.txt"), "hello")
  75. yield* mkdir(path.join(dir, ".opencode"))
  76. yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
  77. }),
  78. )
  79. const files = yield* collectFiles({ cwd: dir, hidden: false })
  80. expect(files.includes("visible.txt")).toBe(true)
  81. expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
  82. }),
  83. )
  84. it.live("search returns empty when nothing matches", () =>
  85. Effect.gen(function* () {
  86. const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const value = 'other'\n"))
  87. const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle" })
  88. expect(result.partial).toBe(false)
  89. expect(result.items).toEqual([])
  90. }),
  91. )
  92. it.live("search returns match metadata with normalized path", () =>
  93. Effect.gen(function* () {
  94. const dir = yield* tmpdir((dir) =>
  95. Effect.gen(function* () {
  96. yield* mkdir(path.join(dir, "src"))
  97. yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
  98. }),
  99. )
  100. const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle" })
  101. expect(result.partial).toBe(false)
  102. expect(result.items).toHaveLength(1)
  103. expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts"))
  104. expect(result.items[0]?.line_number).toBe(1)
  105. expect(result.items[0]?.lines.text).toContain("needle")
  106. }),
  107. )
  108. it.live("search returns matched rows with glob filter", () =>
  109. Effect.gen(function* () {
  110. const dir = yield* tmpdir((dir) =>
  111. Effect.gen(function* () {
  112. yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
  113. yield* write(path.join(dir, "skip.txt"), "const value = 'other'\n")
  114. }),
  115. )
  116. const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle", glob: ["*.ts"] })
  117. expect(result.partial).toBe(false)
  118. expect(result.items).toHaveLength(1)
  119. expect(result.items[0]?.path.text).toContain("match.ts")
  120. expect(result.items[0]?.lines.text).toContain("needle")
  121. }),
  122. )
  123. it.live("search supports explicit file targets", () =>
  124. Effect.gen(function* () {
  125. const dir = yield* tmpdir((dir) =>
  126. Effect.gen(function* () {
  127. yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
  128. yield* write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
  129. }),
  130. )
  131. const file = path.join(dir, "match.ts")
  132. const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle", file: [file] })
  133. expect(result.partial).toBe(false)
  134. expect(result.items).toHaveLength(1)
  135. expect(result.items[0]?.path.text).toBe(file)
  136. }),
  137. )
  138. it.live("files returns empty when glob matches no files", () =>
  139. Effect.gen(function* () {
  140. const dir = yield* tmpdir((dir) =>
  141. Effect.gen(function* () {
  142. yield* mkdir(path.join(dir, "packages", "console"))
  143. yield* write(path.join(dir, "packages", "console", "package.json"), "{}")
  144. }),
  145. )
  146. const files = yield* collectFiles({ cwd: dir, glob: ["packages/*"] })
  147. expect(files).toEqual([])
  148. }),
  149. )
  150. it.live("files returns stream of filenames", () =>
  151. Effect.gen(function* () {
  152. const dir = yield* tmpdir((dir) =>
  153. Effect.gen(function* () {
  154. yield* write(path.join(dir, "a.txt"), "hello")
  155. yield* write(path.join(dir, "b.txt"), "world")
  156. }),
  157. )
  158. const files = yield* collectFiles({ cwd: dir }).pipe(Effect.map((files) => files.sort()))
  159. expect(files).toEqual(["a.txt", "b.txt"])
  160. }),
  161. )
  162. it.live("files respects glob filter", () =>
  163. Effect.gen(function* () {
  164. const dir = yield* tmpdir((dir) =>
  165. Effect.gen(function* () {
  166. yield* write(path.join(dir, "keep.ts"), "yes")
  167. yield* write(path.join(dir, "skip.txt"), "no")
  168. }),
  169. )
  170. const files = yield* collectFiles({ cwd: dir, glob: ["*.ts"] })
  171. expect(files).toEqual(["keep.ts"])
  172. }),
  173. )
  174. it.live("files dies on nonexistent directory", () =>
  175. Effect.gen(function* () {
  176. const exit = yield* Ripgrep.Service.use((rg) =>
  177. rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect),
  178. ).pipe(Effect.exit)
  179. expect(exit._tag).toBe("Failure")
  180. }),
  181. )
  182. it.live("ignores RIPGREP_CONFIG_PATH in direct mode", () =>
  183. Effect.gen(function* () {
  184. const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
  185. const result = yield* withRipgrepConfig(
  186. path.join(dir, "missing-ripgreprc"),
  187. Ripgrep.use.search({ cwd: dir, pattern: "needle" }),
  188. )
  189. expect(result.items).toHaveLength(1)
  190. }),
  191. )
  192. it.live("ignores RIPGREP_CONFIG_PATH in worker mode", () =>
  193. Effect.gen(function* () {
  194. const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
  195. const result = yield* withRipgrepConfig(
  196. path.join(dir, "missing-ripgreprc"),
  197. Ripgrep.use.search({ cwd: dir, pattern: "needle" }),
  198. )
  199. expect(result.items).toHaveLength(1)
  200. }),
  201. )
  202. })