| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276 |
- 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 { 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 { ProjectReference } from "@opencode-ai/core/project-reference"
- 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"
- const inertReferences = references({})
- function provide(directory: string, projectReferences = inertReferences, data = Global.Path.data) {
- const dependencies = Layer.mergeAll(
- FSUtil.defaultLayer,
- FileSystemRipgrep.defaultLayer,
- AppProcess.defaultLayer,
- Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
- Layer.succeed(ProjectReference.Service, projectReferences),
- 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, inertReferences, 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 and named local reference", () =>
- 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")])
- const guide = yield* Effect.promise(() => fs.realpath(path.join(docs, "guide.md")))
- expect((yield* search.files({ pattern: "*.md", reference: "docs" })).items).toMatchObject([
- { path: RelativePath.make("guide.md"), resource: "docs:guide.md", canonical: guide },
- ])
- }).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
- }),
- )
- 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()
- })
- })
- function references(entries: Record<string, ProjectReference.Resolved>) {
- return ProjectReference.Service.of({
- list: () => Effect.succeed(Object.values(entries)),
- get: (name) => Effect.succeed(entries[name]),
- resolveMention: () => Effect.succeed(undefined),
- ensurePath: () => Effect.void,
- containsManagedPath: () => Effect.succeed(false),
- })
- }
|