ripgrep.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  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("defaults to include hidden", () =>
  48. Effect.gen(function* () {
  49. const dir = yield* tmpdir((dir) =>
  50. Effect.gen(function* () {
  51. yield* write(path.join(dir, "visible.txt"), "hello")
  52. yield* mkdir(path.join(dir, ".opencode"))
  53. yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
  54. }),
  55. )
  56. const files = yield* collectFiles({ cwd: dir })
  57. expect(files.includes("visible.txt")).toBe(true)
  58. expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
  59. }),
  60. )
  61. it.live("hidden false excludes hidden", () =>
  62. Effect.gen(function* () {
  63. const dir = yield* tmpdir((dir) =>
  64. Effect.gen(function* () {
  65. yield* write(path.join(dir, "visible.txt"), "hello")
  66. yield* mkdir(path.join(dir, ".opencode"))
  67. yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
  68. }),
  69. )
  70. const files = yield* collectFiles({ cwd: dir, hidden: false })
  71. expect(files.includes("visible.txt")).toBe(true)
  72. expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
  73. }),
  74. )
  75. it.live("search returns empty when nothing matches", () =>
  76. Effect.gen(function* () {
  77. const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const value = 'other'\n"))
  78. const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle" })
  79. expect(result.partial).toBe(false)
  80. expect(result.items).toEqual([])
  81. }),
  82. )
  83. it.live("search returns match metadata with normalized path", () =>
  84. Effect.gen(function* () {
  85. const dir = yield* tmpdir((dir) =>
  86. Effect.gen(function* () {
  87. yield* mkdir(path.join(dir, "src"))
  88. yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
  89. }),
  90. )
  91. const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle" })
  92. expect(result.partial).toBe(false)
  93. expect(result.items).toHaveLength(1)
  94. expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts"))
  95. expect(result.items[0]?.line_number).toBe(1)
  96. expect(result.items[0]?.lines.text).toContain("needle")
  97. }),
  98. )
  99. it.live("search returns matched rows with glob filter", () =>
  100. Effect.gen(function* () {
  101. const dir = yield* tmpdir((dir) =>
  102. Effect.gen(function* () {
  103. yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
  104. yield* write(path.join(dir, "skip.txt"), "const value = 'other'\n")
  105. }),
  106. )
  107. const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle", glob: ["*.ts"] })
  108. expect(result.partial).toBe(false)
  109. expect(result.items).toHaveLength(1)
  110. expect(result.items[0]?.path.text).toContain("match.ts")
  111. expect(result.items[0]?.lines.text).toContain("needle")
  112. }),
  113. )
  114. it.live("search supports explicit file targets", () =>
  115. Effect.gen(function* () {
  116. const dir = yield* tmpdir((dir) =>
  117. Effect.gen(function* () {
  118. yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
  119. yield* write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
  120. }),
  121. )
  122. const file = path.join(dir, "match.ts")
  123. const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle", file: [file] })
  124. expect(result.partial).toBe(false)
  125. expect(result.items).toHaveLength(1)
  126. expect(result.items[0]?.path.text).toBe(file)
  127. }),
  128. )
  129. it.live("files returns empty when glob matches no files", () =>
  130. Effect.gen(function* () {
  131. const dir = yield* tmpdir((dir) =>
  132. Effect.gen(function* () {
  133. yield* mkdir(path.join(dir, "packages", "console"))
  134. yield* write(path.join(dir, "packages", "console", "package.json"), "{}")
  135. }),
  136. )
  137. const files = yield* collectFiles({ cwd: dir, glob: ["packages/*"] })
  138. expect(files).toEqual([])
  139. }),
  140. )
  141. it.live("files returns stream of filenames", () =>
  142. Effect.gen(function* () {
  143. const dir = yield* tmpdir((dir) =>
  144. Effect.gen(function* () {
  145. yield* write(path.join(dir, "a.txt"), "hello")
  146. yield* write(path.join(dir, "b.txt"), "world")
  147. }),
  148. )
  149. const files = yield* collectFiles({ cwd: dir }).pipe(Effect.map((files) => files.sort()))
  150. expect(files).toEqual(["a.txt", "b.txt"])
  151. }),
  152. )
  153. it.live("files respects glob filter", () =>
  154. Effect.gen(function* () {
  155. const dir = yield* tmpdir((dir) =>
  156. Effect.gen(function* () {
  157. yield* write(path.join(dir, "keep.ts"), "yes")
  158. yield* write(path.join(dir, "skip.txt"), "no")
  159. }),
  160. )
  161. const files = yield* collectFiles({ cwd: dir, glob: ["*.ts"] })
  162. expect(files).toEqual(["keep.ts"])
  163. }),
  164. )
  165. it.live("files dies on nonexistent directory", () =>
  166. Effect.gen(function* () {
  167. const exit = yield* Ripgrep.Service.use((rg) =>
  168. rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect),
  169. ).pipe(Effect.exit)
  170. expect(exit._tag).toBe("Failure")
  171. }),
  172. )
  173. it.live("ignores RIPGREP_CONFIG_PATH in direct mode", () =>
  174. Effect.gen(function* () {
  175. const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
  176. const result = yield* withRipgrepConfig(
  177. path.join(dir, "missing-ripgreprc"),
  178. Ripgrep.use.search({ cwd: dir, pattern: "needle" }),
  179. )
  180. expect(result.items).toHaveLength(1)
  181. }),
  182. )
  183. it.live("ignores RIPGREP_CONFIG_PATH in worker mode", () =>
  184. Effect.gen(function* () {
  185. const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
  186. const result = yield* withRipgrepConfig(
  187. path.join(dir, "missing-ripgreprc"),
  188. Ripgrep.use.search({ cwd: dir, pattern: "needle" }),
  189. )
  190. expect(result.items).toHaveLength(1)
  191. }),
  192. )
  193. })