ripgrep.test.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import { describe, expect } from "bun:test"
  2. import fs from "fs/promises"
  3. import path from "path"
  4. import { Effect } from "effect"
  5. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  6. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  7. import { Location } from "@opencode-ai/core/location"
  8. import { Ripgrep } from "@opencode-ai/core/ripgrep"
  9. import { RelativePath } from "@opencode-ai/core/schema"
  10. import { tmpdir } from "./fixture/tmpdir"
  11. import { testEffect } from "./lib/effect"
  12. import { tempLocationLayer } from "./fixture/location"
  13. const it = testEffect(AppNodeBuilder.build(Ripgrep.node, [[Location.node, tempLocationLayer]]))
  14. describe("Ripgrep", () => {
  15. it.live("globs files as an array", () =>
  16. Effect.acquireUseRelease(
  17. Effect.promise(() => tmpdir()),
  18. (tmp) =>
  19. Effect.gen(function* () {
  20. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "src")))
  21. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "match.ts"), "needle\n"))
  22. const result = yield* (yield* Ripgrep.Service).glob({ cwd: tmp.path, pattern: "**/*.ts", limit: 10 })
  23. expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")])
  24. }),
  25. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  26. ),
  27. )
  28. it.live("greps files with include filtering", () =>
  29. Effect.acquireUseRelease(
  30. Effect.promise(() => tmpdir()),
  31. (tmp) =>
  32. Effect.gen(function* () {
  33. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "src")))
  34. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "match.ts"), "needle\n"))
  35. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "skip.txt"), "needle\n"))
  36. const result = yield* (yield* Ripgrep.Service).grep({
  37. cwd: tmp.path,
  38. pattern: "needle",
  39. include: "*.ts",
  40. limit: 10,
  41. })
  42. expect(result).toHaveLength(1)
  43. expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts"))
  44. expect(result[0]?.submatches[0]?.text).toBe("needle")
  45. }),
  46. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  47. ),
  48. )
  49. it.live("keeps ignored files out of catch-all find results", () =>
  50. Effect.acquireUseRelease(
  51. Effect.promise(() => tmpdir()),
  52. (tmp) =>
  53. Effect.gen(function* () {
  54. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "node_modules", "pkg"), { recursive: true }))
  55. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "src")))
  56. yield* Effect.promise(() => Bun.$`git init -q ${tmp.path}`)
  57. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, ".gitignore"), "node_modules/\n"))
  58. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "node_modules", "pkg", "index.js"), "ignored\n"))
  59. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "index.js"), "included\n"))
  60. const files = yield* (yield* Ripgrep.Service).find({ cwd: tmp.path, pattern: "*", limit: 10 })
  61. expect(files.map((item) => item.path)).toContain(RelativePath.make("src/index.js"))
  62. expect(files.map((item) => item.path)).not.toContain(RelativePath.make("node_modules/pkg/index.js"))
  63. }),
  64. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  65. ),
  66. )
  67. it.live("never includes git metadata", () =>
  68. Effect.acquireUseRelease(
  69. Effect.promise(() => tmpdir()),
  70. (tmp) =>
  71. Effect.gen(function* () {
  72. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".opencode")))
  73. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, ".opencode", "config"), "needle\n"))
  74. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".git")))
  75. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, ".git", "config"), "needle\n"))
  76. const ripgrep = yield* Ripgrep.Service
  77. const files = yield* ripgrep.find({ cwd: tmp.path, pattern: "**/*", limit: 10 })
  78. expect(files.map((item) => item.path)).toContain(RelativePath.make(".opencode/config"))
  79. expect(files.map((item) => item.path)).not.toContain(RelativePath.make(".git/config"))
  80. const observed: string[] = []
  81. const limited = yield* ripgrep.find({
  82. cwd: tmp.path,
  83. pattern: "**/*",
  84. limit: 1,
  85. onEntry: (entry) => Effect.sync(() => observed.push(entry.path)),
  86. })
  87. expect(observed).toEqual(limited.map((item) => item.path))
  88. const matches = yield* ripgrep.grep({ cwd: tmp.path, pattern: "needle", include: "config", limit: 10 })
  89. expect(matches.map((item) => item.entry.path)).toContain(RelativePath.make(".opencode/config"))
  90. expect(matches.map((item) => item.entry.path)).not.toContain(RelativePath.make(".git/config"))
  91. }),
  92. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  93. ),
  94. )
  95. it.live("excludes protected directory trees from catch-all find results", () =>
  96. Effect.acquireUseRelease(
  97. Effect.promise(() => tmpdir()),
  98. (tmp) =>
  99. Effect.gen(function* () {
  100. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "Pictures")))
  101. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "Pictures", "private.jpg"), "private\n"))
  102. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "visible.txt"), "visible\n"))
  103. const files = yield* (yield* Ripgrep.Service).find({
  104. cwd: tmp.path,
  105. pattern: "*",
  106. limit: 10,
  107. exclude: ["Pictures/**"],
  108. })
  109. expect(files.map((item) => item.path)).toContain(RelativePath.make("visible.txt"))
  110. expect(files.map((item) => item.path)).not.toContain(RelativePath.make("Pictures/private.jpg"))
  111. }),
  112. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  113. ),
  114. )
  115. it.live("returns a bounded preview for matches on oversized lines", () =>
  116. Effect.acquireUseRelease(
  117. Effect.promise(() => tmpdir()),
  118. (tmp) =>
  119. Effect.gen(function* () {
  120. yield* Effect.promise(() =>
  121. fs.writeFile(path.join(tmp.path, "generated.ts"), `Cloudflare${"x".repeat(70 * 1024)}\n`),
  122. )
  123. const matches = yield* (yield* Ripgrep.Service).grep({
  124. cwd: tmp.path,
  125. pattern: "Cloudflare",
  126. limit: 10,
  127. })
  128. expect(matches).toHaveLength(1)
  129. expect(matches[0]?.entry.path).toBe(RelativePath.make("generated.ts"))
  130. expect(matches[0]?.text).toHaveLength(2_003)
  131. expect(matches[0]?.text.endsWith("...")).toBe(true)
  132. expect(matches[0]?.submatches).toEqual([{ text: "Cloudflare", start: 0, end: 10 }])
  133. }),
  134. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  135. ),
  136. )
  137. })