search.test.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import { describe, expect } from "bun:test"
  2. import fs from "fs/promises"
  3. import os from "os"
  4. import path from "path"
  5. import { Effect } from "effect"
  6. import { Fff } from "#fff"
  7. import { Search } from "@opencode-ai/core/filesystem/search"
  8. import { testEffect } from "../lib/effect"
  9. const it = testEffect(Search.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(() => fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })).pipe(
  15. Effect.ignore,
  16. ),
  17. ).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
  18. const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
  19. const waitForFileIndex = (search: Search.Interface, cwd: string) =>
  20. search.glob({ cwd, pattern: "**/*", limit: 1 }).pipe(Effect.ignore)
  21. describe("file.search", () => {
  22. it.live("uses fff for Bun-backed grep", () =>
  23. Effect.gen(function* () {
  24. expect(Fff.available()).toBe(true)
  25. const dir = yield* tmpdir()
  26. yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
  27. const search = yield* Search.Service
  28. const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 })
  29. expect(result.engine).toBe("fff")
  30. expect(result.items).toHaveLength(1)
  31. expect(result.items[0]?.path.text).toBe("src/match.ts")
  32. }),
  33. )
  34. it.live("keeps fuzzy file abbreviation matches", () =>
  35. Effect.gen(function* () {
  36. expect(Fff.available()).toBe(true)
  37. const dir = yield* tmpdir()
  38. yield* write(path.join(dir, "README.md"), "hello\n")
  39. const search = yield* Search.Service
  40. yield* waitForFileIndex(search, dir)
  41. const results = yield* search.file({ cwd: dir, query: "rdme", limit: 10 })
  42. expect(results).toContain("README.md")
  43. }),
  44. )
  45. it.live("keeps empty file query candidates", () =>
  46. Effect.gen(function* () {
  47. expect(Fff.available()).toBe(true)
  48. const dir = yield* tmpdir()
  49. yield* write(path.join(dir, "README.md"), "hello\n")
  50. yield* write(path.join(dir, "src", "main.ts"), "export const main = true\n")
  51. const search = yield* Search.Service
  52. yield* waitForFileIndex(search, dir)
  53. const results = yield* search.file({ cwd: dir, query: "", limit: 10, kind: "all" })
  54. expect(results).toContain("README.md")
  55. expect(results).toContain("src/")
  56. expect(results).not.toContain("")
  57. }),
  58. )
  59. it.live("stabilizes equal score file candidates by path length", () =>
  60. Effect.gen(function* () {
  61. expect(Fff.available()).toBe(true)
  62. const dir = yield* tmpdir()
  63. yield* write(path.join(dir, "src", "longer-name.ts"), "export const longer = true\n")
  64. yield* write(path.join(dir, "a.ts"), "export const shorter = true\n")
  65. const search = yield* Search.Service
  66. yield* waitForFileIndex(search, dir)
  67. const results = yield* search.file({ cwd: dir, query: "", limit: 10 })
  68. expect(results?.slice(0, 2)).toEqual(["a.ts", "src/longer-name.ts"])
  69. }),
  70. )
  71. it.live("keeps paging grep results without an explicit limit", () =>
  72. Effect.gen(function* () {
  73. expect(Fff.available()).toBe(true)
  74. const dir = yield* tmpdir()
  75. yield* write(path.join(dir, "matches.txt"), Array.from({ length: 150 }, (_, idx) => `needle ${idx}\n`).join(""))
  76. const search = yield* Search.Service
  77. const result = yield* search.search({ cwd: dir, pattern: "needle" })
  78. expect(result.items).toHaveLength(150)
  79. }),
  80. )
  81. it.live("uses byte ranges for UTF-8 grep submatches", () =>
  82. Effect.gen(function* () {
  83. expect(Fff.available()).toBe(true)
  84. const dir = yield* tmpdir()
  85. yield* write(path.join(dir, "unicode.txt"), "éneedle\n")
  86. const search = yield* Search.Service
  87. const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 })
  88. expect(result.items[0]?.submatches[0]?.match.text).toBe("needle")
  89. }),
  90. )
  91. it.live("post-filters fff grep include matches", () =>
  92. Effect.gen(function* () {
  93. expect(Fff.available()).toBe(true)
  94. const dir = yield* tmpdir()
  95. yield* write(path.join(dir, "src", "match.ts"), "needle\n")
  96. yield* write(path.join(dir, "src", "match.txt"), "needle\n")
  97. const search = yield* Search.Service
  98. const result = yield* search.search({ cwd: dir, pattern: "needle", glob: ["*.ts"], limit: 10 })
  99. expect(result.engine).toBe("fff")
  100. expect(result.items.map((entry) => entry.path.text)).toEqual(["src/match.ts"])
  101. }),
  102. )
  103. it.live("keeps fff grep include no-match results", () =>
  104. Effect.gen(function* () {
  105. expect(Fff.available()).toBe(true)
  106. const dir = yield* tmpdir()
  107. yield* write(path.join(dir, "src", "match.ts"), "needle\n")
  108. const search = yield* Search.Service
  109. const result = yield* search.search({ cwd: dir, pattern: "missing", glob: ["*.ts"], limit: 10 })
  110. expect(result.engine).toBe("fff")
  111. expect(result.items).toEqual([])
  112. }),
  113. )
  114. it.live("post-filters fff glob matches", () =>
  115. Effect.gen(function* () {
  116. expect(Fff.available()).toBe(true)
  117. const dir = yield* tmpdir()
  118. yield* write(path.join(dir, "src", "match.ts"), "export const value = 1\n")
  119. yield* write(path.join(dir, "src", "match.txt"), "hello\n")
  120. const search = yield* Search.Service
  121. const result = yield* search.glob({ cwd: dir, pattern: "**/*.ts", limit: 10 })
  122. expect(result.files).toEqual([path.join(dir, "src", "match.ts")])
  123. }),
  124. )
  125. it.live("tracks an opened file against its originating query", () =>
  126. Effect.gen(function* () {
  127. expect(Fff.available()).toBe(true)
  128. const dir = yield* tmpdir()
  129. yield* write(path.join(dir, "alpha-target-one.ts"), "export const one = 1\n")
  130. yield* write(path.join(dir, "alpha-target-two.ts"), "export const two = 2\n")
  131. const search = yield* Search.Service
  132. yield* waitForFileIndex(search, dir)
  133. const results = yield* search.file({ cwd: dir, query: "alpha target two", limit: 10 })
  134. expect(results).toContain("alpha-target-two.ts")
  135. // open() records the query->file association in fff's history db via the
  136. // live picker. It must resolve a remembered file and run without error.
  137. yield* search.open({ cwd: dir, file: "alpha-target-two.ts" })
  138. }),
  139. )
  140. })