refactor(core): unify filesystem search service (#31566)

This commit is contained in:
Dax 2026-06-09 20:38:02 -04:00 committed by GitHub
commit a0409e64d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 962 additions and 2852 deletions

View file

@ -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)
}),
)
})

View file

@ -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" })
}),
)
})

View file

@ -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)),
),
),
),
)
})

View file

@ -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"), () => {}))

View file

@ -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()
})
})

View 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]()),
),
)
})

View file

@ -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.)",
)
}),
)
})

View file

@ -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)))
}),
),
)
})