refactor(core): unify filesystem search service (#31566)
This commit is contained in:
parent
ce4e658e3f
commit
a0409e64d8
57 changed files with 962 additions and 2852 deletions
|
|
@ -1,231 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Ripgrep.defaultLayer)
|
||||
|
||||
const tmpdir = (init?: (dir: string) => Effect.Effect<void>) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))),
|
||||
(dir) =>
|
||||
Effect.promise(() =>
|
||||
fs.rm(dir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
}),
|
||||
).pipe(Effect.ignore),
|
||||
).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
|
||||
|
||||
const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
|
||||
const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
|
||||
const collectFiles = (input: Ripgrep.FilesInput) =>
|
||||
Ripgrep.Service.use((rg) =>
|
||||
rg.files(input).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.map((c) => [...c]),
|
||||
),
|
||||
)
|
||||
|
||||
const withRipgrepConfig = <A, E, R>(value: string, effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const prev = process.env["RIPGREP_CONFIG_PATH"]
|
||||
process.env["RIPGREP_CONFIG_PATH"] = value
|
||||
return prev
|
||||
}),
|
||||
() => effect,
|
||||
(prev) =>
|
||||
Effect.sync(() => {
|
||||
if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
|
||||
else process.env["RIPGREP_CONFIG_PATH"] = prev
|
||||
}),
|
||||
)
|
||||
|
||||
describe("file.ripgrep", () => {
|
||||
it.live("exposes a cached managed executable filepath", () =>
|
||||
Effect.gen(function* () {
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const first = yield* ripgrep.filepath
|
||||
const second = yield* ripgrep.filepath
|
||||
|
||||
expect(first).toBe(second)
|
||||
expect((yield* Effect.promise(() => fs.stat(first))).isFile()).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("defaults to include hidden", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "visible.txt"), "hello")
|
||||
yield* mkdir(path.join(dir, ".opencode"))
|
||||
yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir })
|
||||
expect(files.includes("visible.txt")).toBe(true)
|
||||
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("hidden false excludes hidden", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "visible.txt"), "hello")
|
||||
yield* mkdir(path.join(dir, ".opencode"))
|
||||
yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir, hidden: false })
|
||||
expect(files.includes("visible.txt")).toBe(true)
|
||||
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("search returns empty when nothing matches", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const value = 'other'\n"))
|
||||
|
||||
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle" })
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("search returns match metadata with normalized path", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* mkdir(path.join(dir, "src"))
|
||||
yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle" })
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts"))
|
||||
expect(result.items[0]?.line_number).toBe(1)
|
||||
expect(result.items[0]?.lines.text).toContain("needle")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("search returns matched rows with glob filter", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
|
||||
yield* write(path.join(dir, "skip.txt"), "const value = 'other'\n")
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle", glob: ["*.ts"] })
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.path.text).toContain("match.ts")
|
||||
expect(result.items[0]?.lines.text).toContain("needle")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("search supports explicit file targets", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
|
||||
yield* write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
|
||||
}),
|
||||
)
|
||||
|
||||
const file = path.join(dir, "match.ts")
|
||||
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle", file: [file] })
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.path.text).toBe(file)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files returns empty when glob matches no files", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* mkdir(path.join(dir, "packages", "console"))
|
||||
yield* write(path.join(dir, "packages", "console", "package.json"), "{}")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir, glob: ["packages/*"] })
|
||||
expect(files).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files returns stream of filenames", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "a.txt"), "hello")
|
||||
yield* write(path.join(dir, "b.txt"), "world")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir }).pipe(Effect.map((files) => files.sort()))
|
||||
expect(files).toEqual(["a.txt", "b.txt"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files respects glob filter", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "keep.ts"), "yes")
|
||||
yield* write(path.join(dir, "skip.txt"), "no")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir, glob: ["*.ts"] })
|
||||
expect(files).toEqual(["keep.ts"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files dies on nonexistent directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Ripgrep.Service.use((rg) =>
|
||||
rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect),
|
||||
).pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores RIPGREP_CONFIG_PATH in direct mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
|
||||
|
||||
const result = yield* withRipgrepConfig(
|
||||
path.join(dir, "missing-ripgreprc"),
|
||||
Ripgrep.use.search({ cwd: dir, pattern: "needle" }),
|
||||
)
|
||||
expect(result.items).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores RIPGREP_CONFIG_PATH in worker mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
|
||||
|
||||
const result = yield* withRipgrepConfig(
|
||||
path.join(dir, "missing-ripgreprc"),
|
||||
Ripgrep.use.search({ cwd: dir, pattern: "needle" }),
|
||||
)
|
||||
expect(result.items).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,176 +1,44 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Fff } from "#fff"
|
||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Search.defaultLayer)
|
||||
const it = testEffect(Ripgrep.defaultLayer)
|
||||
|
||||
const tmpdir = (init?: (dir: string) => Effect.Effect<void>) =>
|
||||
const withTmp = <A, E, R>(f: (directory: AbsolutePath) => Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))),
|
||||
(dir) =>
|
||||
Effect.promise(() => fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })).pipe(
|
||||
Effect.ignore,
|
||||
),
|
||||
).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(AbsolutePath.make(tmp.path))))
|
||||
|
||||
const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
|
||||
const waitForFileIndex = (search: Search.Interface, cwd: string) =>
|
||||
search.glob({ cwd, pattern: "**/*", limit: 1 }).pipe(Effect.ignore)
|
||||
|
||||
describe("file.search", () => {
|
||||
it.live("uses fff for Bun-backed grep", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 })
|
||||
|
||||
expect(result.engine).toBe("fff")
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.path.text).toBe("src/match.ts")
|
||||
}),
|
||||
describe("Ripgrep", () => {
|
||||
it.live("globs files as an array", () =>
|
||||
withTmp((cwd) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
|
||||
const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 })
|
||||
expect(result.map((item) => item.path)).toEqual([RelativePath.make(path.join("src", "match.ts"))])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps fuzzy file abbreviation matches", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "README.md"), "hello\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
yield* waitForFileIndex(search, dir)
|
||||
const results = yield* search.file({ cwd: dir, query: "rdme", limit: 10 })
|
||||
|
||||
expect(results).toContainEqual({ path: "README.md", type: "file" })
|
||||
}),
|
||||
it.live("greps files with include filtering", () =>
|
||||
withTmp((cwd) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n"))
|
||||
const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 })
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.entry.path).toBe(RelativePath.make(path.join("src", "match.ts")))
|
||||
expect(result[0]?.submatches[0]?.text).toBe("needle")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps empty file query candidates", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "README.md"), "hello\n")
|
||||
yield* write(path.join(dir, "src", "main.ts"), "export const main = true\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
yield* waitForFileIndex(search, dir)
|
||||
const results = yield* search.file({ cwd: dir, query: "", limit: 10, kind: "all" })
|
||||
|
||||
expect(results).toContainEqual({ path: "README.md", type: "file" })
|
||||
expect(results).toContainEqual({ path: "src/", type: "directory" })
|
||||
expect(results.map((item) => item.path)).not.toContain("")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("stabilizes equal score file candidates by path length", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "src", "longer-name.ts"), "export const longer = true\n")
|
||||
yield* write(path.join(dir, "a.ts"), "export const shorter = true\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
yield* waitForFileIndex(search, dir)
|
||||
const results = yield* search.file({ cwd: dir, query: "", limit: 10 })
|
||||
|
||||
expect(results.slice(0, 2)).toEqual([
|
||||
{ path: "a.ts", type: "file" },
|
||||
{ path: "src/longer-name.ts", type: "file" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps paging grep results without an explicit limit", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "matches.txt"), Array.from({ length: 150 }, (_, idx) => `needle ${idx}\n`).join(""))
|
||||
|
||||
const search = yield* Search.Service
|
||||
const result = yield* search.search({ cwd: dir, pattern: "needle" })
|
||||
|
||||
expect(result.items).toHaveLength(150)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses byte ranges for UTF-8 grep submatches", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "unicode.txt"), "éneedle\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 })
|
||||
|
||||
expect(result.items[0]?.submatches[0]?.match.text).toBe("needle")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("post-filters fff grep include matches", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "src", "match.ts"), "needle\n")
|
||||
yield* write(path.join(dir, "src", "match.txt"), "needle\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
const result = yield* search.search({ cwd: dir, pattern: "needle", glob: ["*.ts"], limit: 10 })
|
||||
|
||||
expect(result.engine).toBe("fff")
|
||||
expect(result.items.map((entry) => entry.path.text)).toEqual(["src/match.ts"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps fff grep include no-match results", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "src", "match.ts"), "needle\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
const result = yield* search.search({ cwd: dir, pattern: "missing", glob: ["*.ts"], limit: 10 })
|
||||
|
||||
expect(result.engine).toBe("fff")
|
||||
expect(result.items).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("post-filters fff glob matches", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "src", "match.ts"), "export const value = 1\n")
|
||||
yield* write(path.join(dir, "src", "match.txt"), "hello\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
const result = yield* search.glob({ cwd: dir, pattern: "**/*.ts", limit: 10 })
|
||||
|
||||
expect(result.files).toEqual([path.join(dir, "src", "match.ts")])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("tracks an opened file against its originating query", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "alpha-target-one.ts"), "export const one = 1\n")
|
||||
yield* write(path.join(dir, "alpha-target-two.ts"), "export const two = 2\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
yield* waitForFileIndex(search, dir)
|
||||
const results = yield* search.file({ cwd: dir, query: "alpha target two", limit: 10 })
|
||||
expect(results).toContainEqual({ path: "alpha-target-two.ts", type: "file" })
|
||||
|
||||
// open() records the query->file association in fff's history db via the
|
||||
// live picker. It must resolve a remembered file and run without error.
|
||||
yield* search.open({ cwd: dir, file: "alpha-target-two.ts" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,159 +4,71 @@ import { fileURLToPath } from "url"
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
function provide(directory: string, search = Search.defaultLayer) {
|
||||
return Effect.provide(
|
||||
const provide = (directory: string) =>
|
||||
Effect.provide(
|
||||
FileSystem.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
search,
|
||||
Ripgrep.defaultLayer,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireRelease(
|
||||
const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
}
|
||||
|
||||
describe("FileSystem", () => {
|
||||
it.live("reads complete text and binary files", () =>
|
||||
it.live("reads text and binary files", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const text = Array.from({ length: 3_000 }, (_, index) => `line-${index + 1}`).join("\n")
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "large.txt"), text))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "text.txt"), "hello"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
|
||||
const service = yield* FileSystem.Service
|
||||
const textContent = yield* service.read({ path: RelativePath.make("large.txt") })
|
||||
expect(textContent).toEqual({
|
||||
uri: textContent.uri,
|
||||
name: "large.txt",
|
||||
content: text,
|
||||
encoding: "utf8",
|
||||
mime: "text/plain",
|
||||
})
|
||||
expect(fileURLToPath(textContent.uri)).toBe(path.join(directory, "large.txt"))
|
||||
const binaryContent = yield* service.read({ path: RelativePath.make("data.bin") })
|
||||
expect(binaryContent).toEqual({
|
||||
uri: binaryContent.uri,
|
||||
name: "data.bin",
|
||||
content: "AAEC",
|
||||
encoding: "base64",
|
||||
mime: "application/octet-stream",
|
||||
})
|
||||
expect(fileURLToPath(binaryContent.uri)).toBe(path.join(directory, "data.bin"))
|
||||
const text = yield* service.read({ path: RelativePath.make("text.txt") })
|
||||
const binary = yield* service.read({ path: RelativePath.make("data.bin") })
|
||||
expect(text).toMatchObject({ name: "text.txt", content: "hello", encoding: "utf8", mime: "text/plain" })
|
||||
expect(fileURLToPath(text.uri)).toBe(path.join(directory, "text.txt"))
|
||||
expect(binary).toMatchObject({ name: "data.bin", content: "AAEC", encoding: "base64" })
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("lists direct children with relative paths and resolved URIs", () =>
|
||||
it.live("lists direct children", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
|
||||
const entries = yield* (yield* FileSystem.Service).list()
|
||||
expect(entries.map(({ uri: _uri, ...entry }) => entry)).toEqual([
|
||||
{ path: RelativePath.make("src"), type: "directory", mime: "application/x-directory" },
|
||||
{ path: RelativePath.make("README.md"), type: "file", mime: "text/markdown" },
|
||||
expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
|
||||
{ path: RelativePath.make("src" + path.sep), type: "directory" },
|
||||
{ path: RelativePath.make("README.md"), type: "file" },
|
||||
])
|
||||
expect(
|
||||
yield* Effect.promise(() => Promise.all(entries.map((entry) => fs.realpath(fileURLToPath(entry.uri))))),
|
||||
).toEqual(
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([fs.realpath(path.join(directory, "src")), fs.realpath(path.join(directory, "README.md"))]),
|
||||
),
|
||||
)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects lexical and symlink escapes", () =>
|
||||
it.live("rejects lexical escapes", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* FileSystem.Service
|
||||
expect(
|
||||
Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
|
||||
).toBe(true)
|
||||
if (process.platform === "win32") return
|
||||
const outside = `${directory}-outside.txt`
|
||||
yield* Effect.promise(() => fs.writeFile(outside, "outside"))
|
||||
yield* Effect.promise(() => fs.symlink(outside, path.join(directory, "link.txt")))
|
||||
expect(Exit.isFailure(yield* service.read({ path: RelativePath.make("link.txt") }).pipe(Effect.exit))).toBe(
|
||||
true,
|
||||
)
|
||||
yield* Effect.promise(() => fs.rm(outside, { force: true }))
|
||||
const result = yield* (yield* FileSystem.Service)
|
||||
.read({ path: RelativePath.make("../outside.txt") })
|
||||
.pipe(Effect.exit)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("finds and greps files", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "src", "index.ts"), "const needle = true\n"))
|
||||
const service = yield* FileSystem.Service
|
||||
expect((yield* service.find({ query: "index", type: "file" })).map((item) => item.path)).toEqual([
|
||||
RelativePath.make(path.join("src", "index.ts")),
|
||||
])
|
||||
expect(yield* service.grep({ pattern: "needle" })).toMatchObject([
|
||||
{ path: RelativePath.make(path.join("src", "index.ts")), line: 1, offset: 0 },
|
||||
])
|
||||
}).pipe(
|
||||
provide(
|
||||
directory,
|
||||
Layer.effect(
|
||||
Search.Service,
|
||||
Effect.gen(function* () {
|
||||
const search = yield* Search.Service
|
||||
return Search.Service.of({
|
||||
...search,
|
||||
file: () => Effect.succeed([{ path: path.join("src", "index.ts"), type: "file" }]),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(Search.defaultLayer)),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("uses the type supplied by Search file results", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "selected.ts"), "export {}\n"))
|
||||
expect((yield* (yield* FileSystem.Service).find({ query: "ignored", limit: 1 }))[0]).toMatchObject({
|
||||
path: RelativePath.make("selected.ts"),
|
||||
type: "directory",
|
||||
mime: "application/x-directory",
|
||||
})
|
||||
}).pipe(
|
||||
provide(
|
||||
directory,
|
||||
Layer.effect(
|
||||
Search.Service,
|
||||
Effect.gen(function* () {
|
||||
const search = yield* Search.Service
|
||||
return Search.Service.of({
|
||||
...search,
|
||||
file: () => Effect.succeed([{ path: "selected.ts", type: "directory" }]),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(Search.defaultLayer)),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import { ModelsDev } from "../src/models-dev"
|
|||
import { Npm } from "../src/npm"
|
||||
import { Project } from "../src/project"
|
||||
import { Reference } from "../src/reference"
|
||||
import { LocationSearch } from "../src/location-search"
|
||||
import { ToolRegistry } from "../src/tool/registry"
|
||||
import { ApplicationTools } from "../src/tool/application-tools"
|
||||
|
||||
|
|
@ -28,6 +27,7 @@ const it = testEffect(
|
|||
Layer.merge(
|
||||
applicationTools,
|
||||
LocationServiceMap.layer.pipe(
|
||||
Layer.provide(applicationTools),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
Project.defaultLayer,
|
||||
|
|
@ -72,7 +72,6 @@ describe("LocationServiceMap", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* PluginBoot.Service.use((boot) => boot.wait())
|
||||
yield* Reference.Service
|
||||
yield* LocationSearch.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
|
|
|
|||
|
|
@ -1,260 +0,0 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Cause, Effect, Exit, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { LocationSearch } from "@opencode-ai/core/location-search"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { location } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
function provide(directory: string, data = Global.Path.data) {
|
||||
const dependencies = Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
FileSystemRipgrep.defaultLayer,
|
||||
Search.defaultLayer,
|
||||
AppProcess.defaultLayer,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
Global.layerWith({ data }),
|
||||
)
|
||||
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
|
||||
const search = LocationSearch.layer.pipe(
|
||||
Layer.provide(filesystem),
|
||||
Layer.provide(Ripgrep.layer.pipe(Layer.provide(dependencies))),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(dependencies),
|
||||
)
|
||||
return Effect.provide(Layer.merge(filesystem, search))
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
}
|
||||
|
||||
describe("LocationSearch", () => {
|
||||
it.live("greps an absolute managed tool-output file", () =>
|
||||
withTmp((directory) => {
|
||||
const data = path.join(directory, "data")
|
||||
const managed = path.join(data, "tool-output")
|
||||
const output = path.join(managed, "tool_123")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(managed, { recursive: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(output, "ok\nFAIL here\nok"))
|
||||
const search = yield* LocationSearch.Service
|
||||
const result = yield* search.grep({ pattern: "FAIL", path: output })
|
||||
expect(result.items).toMatchObject([{ canonical: output, line: 2, lines: "FAIL here\n" }])
|
||||
}).pipe(provide(directory, data))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("searches files in the active Location with structured bounded results", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(directory, "src"))
|
||||
await fs.writeFile(path.join(directory, "src", "index.ts"), "export const value = 1\n")
|
||||
await fs.writeFile(path.join(directory, "notes.txt"), "notes\n")
|
||||
})
|
||||
const result = yield* (yield* LocationSearch.Service).files({ pattern: "*.ts" })
|
||||
const canonical = yield* Effect.promise(() => fs.realpath(path.join(directory, "src", "index.ts")))
|
||||
|
||||
expect(result).toMatchObject({ truncated: false, partial: false })
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]).toMatchObject({
|
||||
path: RelativePath.make("src/index.ts"),
|
||||
canonical,
|
||||
resource: "src/index.ts",
|
||||
})
|
||||
expect(typeof result.items[0].mtime).toBe("number")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("searches files under a relative subdirectory", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(directory, "src"))
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(path.join(directory, "src", "active.ts"), "active\n")
|
||||
await fs.writeFile(path.join(docs, "guide.md"), "guide\n")
|
||||
})
|
||||
const search = yield* LocationSearch.Service
|
||||
|
||||
expect(
|
||||
(yield* search.files({ pattern: "*.ts", path: RelativePath.make("src") })).items.map((item) => item.path),
|
||||
).toEqual([RelativePath.make("src/active.ts")])
|
||||
}).pipe(provide(directory))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("greps the Location, exact relative files and directories, and include globs", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(directory, "src"))
|
||||
await fs.writeFile(path.join(directory, "src", "one.ts"), "needle ts\n")
|
||||
await fs.writeFile(path.join(directory, "src", "two.txt"), "needle txt\n")
|
||||
await fs.writeFile(path.join(directory, "root.md"), "needle root\n")
|
||||
})
|
||||
const search = yield* LocationSearch.Service
|
||||
|
||||
expect((yield* search.grep({ pattern: "needle" })).items.map((item) => item.path).sort()).toEqual([
|
||||
RelativePath.make("root.md"),
|
||||
RelativePath.make("src/one.ts"),
|
||||
RelativePath.make("src/two.txt"),
|
||||
])
|
||||
expect(
|
||||
(yield* search.grep({ pattern: "needle", path: RelativePath.make("src") })).items
|
||||
.map((item) => item.path)
|
||||
.sort(),
|
||||
).toEqual([RelativePath.make("src/one.ts"), RelativePath.make("src/two.txt")])
|
||||
expect((yield* search.grep({ pattern: "needle", path: RelativePath.make("src/one.ts") })).items).toMatchObject([
|
||||
{ path: RelativePath.make("src/one.ts"), resource: "src/one.ts", lines: "needle ts\n", line: 1, offset: 0 },
|
||||
])
|
||||
expect((yield* search.grep({ pattern: "needle", include: "*.ts" })).items.map((item) => item.path)).toEqual([
|
||||
RelativePath.make("src/one.ts"),
|
||||
])
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not discover hidden files during broad V2 searches", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(directory, "nested", ".private"), { recursive: true })
|
||||
await fs.writeFile(path.join(directory, "visible.txt"), "needle visible\n")
|
||||
await fs.writeFile(path.join(directory, ".env"), "needle root secret\n")
|
||||
await fs.writeFile(path.join(directory, "nested", "visible.txt"), "needle nested visible\n")
|
||||
await fs.writeFile(path.join(directory, "nested", ".env"), "needle nested secret\n")
|
||||
await fs.writeFile(path.join(directory, "nested", ".private", "secret.txt"), "needle hidden directory\n")
|
||||
})
|
||||
const search = yield* LocationSearch.Service
|
||||
|
||||
expect((yield* search.files({ pattern: "*" })).items.map((item) => item.path).sort()).toEqual([
|
||||
RelativePath.make("nested/visible.txt"),
|
||||
RelativePath.make("visible.txt"),
|
||||
])
|
||||
expect((yield* search.files({ pattern: ".env" })).items).toEqual([])
|
||||
expect((yield* search.grep({ pattern: "needle", include: "*" })).items.map((item) => item.path).sort()).toEqual(
|
||||
[RelativePath.make("nested/visible.txt"), RelativePath.make("visible.txt")],
|
||||
)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("caps result counts and line previews", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await Promise.all(
|
||||
Array.from({ length: 101 }, (_, index) => fs.writeFile(path.join(directory, `${index}.txt`), "needle\n")),
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(directory, "long.txt"),
|
||||
`needle ${"x".repeat(LocationSearch.MAX_LINE_PREVIEW_LENGTH)}\n`,
|
||||
)
|
||||
})
|
||||
const search = yield* LocationSearch.Service
|
||||
const files = yield* search.files({ pattern: "*.txt", limit: 2 })
|
||||
const hardCappedFiles = yield* search.files({ pattern: "*.txt", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })
|
||||
const hardCappedGrep = yield* search.grep({ pattern: "needle", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })
|
||||
const grep = yield* search.grep({ pattern: "needle", path: RelativePath.make("long.txt") })
|
||||
|
||||
expect(files.items).toHaveLength(2)
|
||||
expect(files.truncated).toBe(true)
|
||||
expect(hardCappedFiles.items).toHaveLength(LocationSearch.MAX_RESULT_LIMIT)
|
||||
expect(hardCappedFiles.truncated).toBe(true)
|
||||
expect(hardCappedGrep.items).toHaveLength(LocationSearch.MAX_RESULT_LIMIT)
|
||||
expect(hardCappedGrep.truncated).toBe(true)
|
||||
expect(grep.items[0].lines).toHaveLength(LocationSearch.MAX_LINE_PREVIEW_LENGTH)
|
||||
expect(grep.items[0].linePreviewTruncated).toBe(true)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports invalid regex as a typed failure", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "notes.txt"), "notes\n"))
|
||||
const exit = yield* (yield* LocationSearch.Service).grep({ pattern: "[" }).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Ripgrep.InvalidPatternError)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects oversized ripgrep JSON records before durable projection", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "huge.txt"), `needle ${"x".repeat(Ripgrep.MAX_RECORD_BYTES)}\n`),
|
||||
)
|
||||
const exit = yield* (yield* LocationSearch.Service).grep({ pattern: "needle" }).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(String(Cause.squash(exit.cause))).toContain("Ripgrep JSON record exceeded")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects lexical and symlink escapes through root resolution", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const outside = `${directory}-outside`
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(outside)
|
||||
await fs.writeFile(path.join(outside, "secret.txt"), "secret\n")
|
||||
await fs.symlink(outside, path.join(directory, "escape"))
|
||||
})
|
||||
const search = yield* LocationSearch.Service
|
||||
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* search.files({ pattern: "*", path: RelativePath.make("../outside") }).pipe(Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
Exit.isFailure(yield* search.files({ pattern: "*", path: RelativePath.make("escape") }).pipe(Effect.exit)),
|
||||
).toBe(true)
|
||||
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("honors a pre-aborted cancellation signal", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const exit = yield* (yield* LocationSearch.Service)
|
||||
.files({ pattern: "*", signal: controller.signal })
|
||||
.pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
test("exposes schema-testable search bounds", () => {
|
||||
const decode = Schema.decodeUnknownSync(LocationSearch.FilesInput)
|
||||
expect(LocationSearch.DEFAULT_RESULT_LIMIT).toBe(100)
|
||||
expect(LocationSearch.MAX_RESULT_LIMIT).toBe(100)
|
||||
expect(LocationSearch.MAX_LINE_PREVIEW_LENGTH).toBe(2_000)
|
||||
expect(() => decode({ pattern: "*", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })).toThrow()
|
||||
})
|
||||
})
|
||||
39
packages/core/test/ripgrep.test.ts
Normal file
39
packages/core/test/ripgrep.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { RelativePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Ripgrep.defaultLayer)
|
||||
|
||||
describe("Ripgrep", () => {
|
||||
it.live("allows caller globs to re-include git metadata", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".opencode")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, ".opencode", "config"), "needle\n"))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".git")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, ".git", "config"), "needle\n"))
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
|
||||
const files = yield* ripgrep.find({ cwd: tmp.path, pattern: "**/*", limit: 10 })
|
||||
expect(files.map((item) => item.path)).toContain(RelativePath.make(".opencode/config"))
|
||||
expect(files.map((item) => item.path)).toContain(RelativePath.make(".git/config"))
|
||||
|
||||
const matches = yield* ripgrep.grep({ cwd: tmp.path, pattern: "needle", include: "config", limit: 10 })
|
||||
expect(matches.map((item) => item.entry.path)).toContain(
|
||||
RelativePath.make(".opencode/config"),
|
||||
)
|
||||
expect(matches.map((item) => item.entry.path)).toContain(
|
||||
RelativePath.make(".git/config"),
|
||||
)
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LocationSearch } from "@opencode-ai/core/location-search"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { RelativePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { GlobTool } from "@opencode-ai/core/tool/glob"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_glob_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const searches: LocationSearch.FilesInput[] = []
|
||||
let allow = true
|
||||
let result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
|
||||
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] }))),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
|
||||
const search = Layer.succeed(
|
||||
LocationSearch.Service,
|
||||
LocationSearch.Service.of({
|
||||
files: (input) =>
|
||||
Effect.sync(() => {
|
||||
searches.push(input)
|
||||
return result
|
||||
}),
|
||||
grep: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const glob = GlobTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(search))
|
||||
const it = testEffect(Layer.mergeAll(registry, permission, search, glob))
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
searches.length = 0
|
||||
allow = true
|
||||
result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
|
||||
}
|
||||
|
||||
const call = (input: typeof GlobTool.Input.Type, id = "call-glob") => ({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call" as const, id, name: "glob", input },
|
||||
})
|
||||
|
||||
describe("GlobTool", () => {
|
||||
it.effect("registers the glob definition", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
expect((yield* toolDefinitions(yield* ToolRegistry.Service)).map((tool) => tool.name)).toEqual(["glob"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("authorizes the active Location pattern and delegates traversal only to LocationSearch.files", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 })),
|
||||
).toEqual({
|
||||
type: "text",
|
||||
value: "No files found",
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
action: "glob",
|
||||
resources: ["**/*.ts"],
|
||||
save: ["*"],
|
||||
metadata: { root: "src", path: "src", limit: 12 },
|
||||
},
|
||||
])
|
||||
expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prevents Location search when permission is denied", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
allow = false
|
||||
|
||||
expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.secret" }))).toEqual({
|
||||
type: "error",
|
||||
value: "Unable to find files matching *.secret",
|
||||
})
|
||||
expect(searches).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns active Location glob resources", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
result = new LocationSearch.FilesResult({
|
||||
items: [
|
||||
new LocationSearch.File({
|
||||
path: RelativePath.make("src/index.ts"),
|
||||
canonical: "/project/src/index.ts",
|
||||
resource: "src/index.ts",
|
||||
mtime: 1,
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
partial: false,
|
||||
})
|
||||
|
||||
expect(yield* settleTool(yield* ToolRegistry.Service, call({ pattern: "*.ts" }))).toEqual({
|
||||
result: { type: "text", value: "src/index.ts" },
|
||||
output: {
|
||||
structured: result,
|
||||
content: [{ type: "text", text: "src/index.ts" }],
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("formats bounded and partial results without discarding structured output", () =>
|
||||
Effect.sync(() => {
|
||||
const output = new LocationSearch.FilesResult({
|
||||
items: [
|
||||
new LocationSearch.File({
|
||||
path: RelativePath.make("one.ts"),
|
||||
canonical: "/project/one.ts",
|
||||
resource: "one.ts",
|
||||
mtime: 1,
|
||||
}),
|
||||
],
|
||||
truncated: true,
|
||||
partial: true,
|
||||
})
|
||||
|
||||
expect(GlobTool.toModelOutput(output)).toBe(
|
||||
"one.ts\n\n(Results are truncated: showing first 1 results. Consider using a more specific path or pattern.)\n\n(Results may be incomplete because some discovered files could not be read.)",
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { LocationSearch } from "@opencode-ai/core/location-search"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { GrepTool } from "@opencode-ai/core/tool/grep"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it as runtimeIt } from "./lib/effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const searches: LocationSearch.GrepInput[] = []
|
||||
let allow = true
|
||||
let result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
|
||||
let searchFailure: Ripgrep.InvalidPatternError | undefined
|
||||
|
||||
const search = Layer.succeed(
|
||||
LocationSearch.Service,
|
||||
LocationSearch.Service.of({
|
||||
files: () => Effect.die("unused"),
|
||||
grep: (input) =>
|
||||
Effect.sync(() => {
|
||||
searches.push(input)
|
||||
if (searchFailure) throw searchFailure
|
||||
return result
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const grep = GrepTool.layer.pipe(Layer.provide(registry), Layer.provide(search), Layer.provide(permission))
|
||||
const it = testEffect(Layer.mergeAll(registry, search, permission, grep))
|
||||
const sessionID = SessionV2.ID.make("ses_grep_tool_test")
|
||||
|
||||
const execute = (input: Record<string, unknown>) =>
|
||||
ToolRegistry.Service.use((registry) =>
|
||||
executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-grep", name: "grep", input },
|
||||
}),
|
||||
)
|
||||
|
||||
const settle = (input: Record<string, unknown>) =>
|
||||
ToolRegistry.Service.use((registry) =>
|
||||
settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-grep", name: "grep", input },
|
||||
}),
|
||||
)
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
searches.length = 0
|
||||
allow = true
|
||||
searchFailure = undefined
|
||||
result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
|
||||
}
|
||||
|
||||
function provideLive(directory: string) {
|
||||
const dependencies = Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
FileSystemRipgrep.defaultLayer,
|
||||
Search.defaultLayer,
|
||||
AppProcess.defaultLayer,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
)
|
||||
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
|
||||
const search = LocationSearch.layer.pipe(
|
||||
Layer.provide(filesystem),
|
||||
Layer.provide(Ripgrep.layer.pipe(Layer.provide(dependencies))),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(dependencies),
|
||||
)
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const grep = GrepTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(filesystem),
|
||||
Layer.provide(search),
|
||||
Layer.provide(permission),
|
||||
)
|
||||
return Layer.mergeAll(registry, filesystem, search, permission, grep)
|
||||
}
|
||||
|
||||
describe("GrepTool", () => {
|
||||
it.effect("registers grep", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
expect(yield* toolDefinitions(yield* ToolRegistry.Service)).toMatchObject([{ name: "grep" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("authorizes the regex resource and delegates an active Location grep", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
const input = { pattern: "needle", path: "src", include: "*.ts", limit: 2 }
|
||||
|
||||
expect(yield* execute(input)).toEqual({ type: "text", value: "No files found" })
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
action: "grep",
|
||||
resources: ["needle"],
|
||||
save: ["*"],
|
||||
metadata: { root: "src", path: RelativePath.make("src"), include: "*.ts", limit: 2 },
|
||||
},
|
||||
])
|
||||
expect(searches).toEqual([{ pattern: "needle", path: RelativePath.make("src"), include: "*.ts", limit: 2 }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not search when permission is denied", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
allow = false
|
||||
|
||||
expect(yield* execute({ pattern: "secret" })).toEqual({ type: "error", value: "Unable to grep for secret" })
|
||||
expect(assertions).toHaveLength(1)
|
||||
expect(searches).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps structured results raw while formatting bounded partial previews for models", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
result = new LocationSearch.GrepResult({
|
||||
items: [
|
||||
new LocationSearch.Match({
|
||||
path: RelativePath.make("src/index.ts"),
|
||||
canonical: "/project/src/index.ts",
|
||||
resource: "src/index.ts",
|
||||
lines: "needle preview",
|
||||
linePreviewTruncated: true,
|
||||
line: 3,
|
||||
offset: 8,
|
||||
submatches: [new LocationSearch.Submatch({ text: "needle", start: 0, end: 6 })],
|
||||
mtime: 1,
|
||||
}),
|
||||
],
|
||||
truncated: true,
|
||||
partial: true,
|
||||
})
|
||||
|
||||
const settlement = yield* settle({ pattern: "needle" })
|
||||
expect(settlement.output?.structured).toEqual(result)
|
||||
expect(settlement.result).toEqual({
|
||||
type: "text",
|
||||
value:
|
||||
"Found 1 matches\nsrc/index.ts:\n Line 3: needle preview...\n\n(Results are truncated: showing first 1 matches. Consider using a more specific path or pattern.)\n\n(Some paths were inaccessible and skipped)",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves an unexpected search defect", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
searchFailure = new Ripgrep.InvalidPatternError({
|
||||
pattern: "[",
|
||||
message: "regex parse error: unclosed character class",
|
||||
})
|
||||
|
||||
expect(Exit.isFailure(yield* execute({ pattern: "[" }).pipe(Effect.exit))).toBe(true)
|
||||
expect(searches).toEqual([{ pattern: "[" }])
|
||||
}),
|
||||
)
|
||||
|
||||
runtimeIt.live("greps active Location files with include globs", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const docs = path.join(tmp.path, "docs")
|
||||
return Effect.gen(function* () {
|
||||
reset()
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "src"))
|
||||
await fs.writeFile(path.join(tmp.path, "src", "index.ts"), "needle ts\n")
|
||||
await fs.writeFile(path.join(tmp.path, "src", "notes.txt"), "needle txt\n")
|
||||
})
|
||||
|
||||
expect(yield* execute({ pattern: "needle", path: "src", include: "*.ts" })).toEqual({
|
||||
type: "text",
|
||||
value: "Found 1 matches\nsrc/index.ts:\n Line 1: needle ts\n",
|
||||
})
|
||||
}).pipe(Effect.provide(provideLive(tmp.path)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue