mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 10:16:03 +00:00
refactor(core): unify filesystem search service (#31566)
This commit is contained in:
@@ -525,6 +525,7 @@
|
|||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@effect/opentelemetry": "catalog:",
|
"@effect/opentelemetry": "catalog:",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
|
"@ff-labs/fff-bun": "0.9.3",
|
||||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
@@ -914,6 +915,7 @@
|
|||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
||||||
|
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
||||||
|
|||||||
@@ -140,6 +140,7 @@
|
|||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
|
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { Context, Effect, Layer, Option, Schema } from "effect"
|
|||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
import { PositiveInt, RelativePath } from "./schema"
|
||||||
import { Search } from "./filesystem/search"
|
import { FileSystemSearch } from "./filesystem/search"
|
||||||
|
import { Entry, Match } from "./filesystem/schema"
|
||||||
|
export { Entry, Match, Submatch } from "./filesystem/schema"
|
||||||
|
|
||||||
export const ReadInput = Schema.Struct({
|
export const ReadInput = Schema.Struct({
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
@@ -28,39 +30,23 @@ export const ListInput = Schema.Struct({
|
|||||||
})
|
})
|
||||||
export type ListInput = typeof ListInput.Type
|
export type ListInput = typeof ListInput.Type
|
||||||
|
|
||||||
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
|
export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({
|
||||||
path: RelativePath,
|
|
||||||
uri: Schema.String,
|
|
||||||
type: Schema.Literals(["file", "directory"]),
|
|
||||||
mime: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export const FindInput = Schema.Struct({
|
|
||||||
query: Schema.String,
|
query: Schema.String,
|
||||||
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
|
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
|
||||||
limit: PositiveInt.pipe(Schema.optional),
|
limit: PositiveInt.pipe(Schema.optional),
|
||||||
})
|
}) {}
|
||||||
export type FindInput = typeof FindInput.Type
|
|
||||||
|
|
||||||
export const GrepInput = Schema.Struct({
|
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
|
||||||
pattern: Schema.String,
|
pattern: Schema.String,
|
||||||
|
path: RelativePath.pipe(Schema.optional),
|
||||||
|
limit: PositiveInt.pipe(Schema.optional),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
|
||||||
|
pattern: Schema.String,
|
||||||
|
path: RelativePath.pipe(Schema.optional),
|
||||||
include: Schema.String.pipe(Schema.optional),
|
include: Schema.String.pipe(Schema.optional),
|
||||||
limit: PositiveInt.pipe(Schema.optional),
|
limit: PositiveInt.pipe(Schema.optional),
|
||||||
})
|
|
||||||
export type GrepInput = typeof GrepInput.Type
|
|
||||||
|
|
||||||
export class GrepMatch extends Schema.Class<GrepMatch>("LocationFileSystem.GrepMatch")({
|
|
||||||
path: RelativePath,
|
|
||||||
lines: Schema.String,
|
|
||||||
line: PositiveInt,
|
|
||||||
offset: NonNegativeInt,
|
|
||||||
submatches: Schema.Array(
|
|
||||||
Schema.Struct({
|
|
||||||
text: Schema.String,
|
|
||||||
start: NonNegativeInt,
|
|
||||||
end: NonNegativeInt,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
@@ -76,17 +62,18 @@ export interface Interface {
|
|||||||
readonly read: (input: ReadInput) => Effect.Effect<Content>
|
readonly read: (input: ReadInput) => Effect.Effect<Content>
|
||||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||||
readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
|
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
|
||||||
|
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem") {}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
const baseLayer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const search = yield* Search.Service
|
const search = yield* FileSystemSearch.Service
|
||||||
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
|
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
|
||||||
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
|
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
|
||||||
const absolute = path.resolve(location.directory, input ?? ".")
|
const absolute = path.resolve(location.directory, input ?? ".")
|
||||||
@@ -96,22 +83,10 @@ export const layer = Layer.effect(
|
|||||||
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
||||||
return { absolute, real, directory: location.directory, root }
|
return { absolute, real, directory: location.directory, root }
|
||||||
})
|
})
|
||||||
const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) {
|
|
||||||
const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
|
|
||||||
if (!real) return
|
|
||||||
if (!FSUtil.contains(selected.root, real)) return
|
|
||||||
const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
|
|
||||||
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
|
|
||||||
if (!type) return
|
|
||||||
return new Entry({
|
|
||||||
path: RelativePath.make(path.relative(selected.directory, absolute)),
|
|
||||||
uri: pathToFileURL(real).href,
|
|
||||||
type,
|
|
||||||
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(real),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
|
find: search.find,
|
||||||
|
glob: search.glob,
|
||||||
|
grep: search.grep,
|
||||||
read: Effect.fn("FileSystem.read")(function* (input) {
|
read: Effect.fn("FileSystem.read")(function* (input) {
|
||||||
const target = yield* resolve(input.path)
|
const target = yield* resolve(input.path)
|
||||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||||
@@ -145,62 +120,28 @@ export const layer = Layer.effect(
|
|||||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||||
return yield* fs.readDirectoryEntries(target.real).pipe(
|
return yield* fs.readDirectoryEntries(target.real).pipe(
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
Effect.flatMap((items) =>
|
|
||||||
Effect.forEach(items, (item) => entry(path.join(target.absolute, item.name), target), {
|
|
||||||
concurrency: "unbounded",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Effect.map((items) =>
|
Effect.map((items) =>
|
||||||
items
|
items
|
||||||
.filter((item): item is Entry => item !== undefined)
|
.flatMap((item) => {
|
||||||
|
if (item.type !== "file" && item.type !== "directory") return []
|
||||||
|
const absolute = path.join(target.absolute, item.name)
|
||||||
|
const relative = path.relative(target.directory, absolute)
|
||||||
|
return [
|
||||||
|
new Entry({
|
||||||
|
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
|
||||||
|
type: item.type,
|
||||||
|
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
|
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
find: Effect.fn("FileSystem.find")(function* (input) {
|
|
||||||
const found = yield* search
|
|
||||||
.file({
|
|
||||||
cwd: location.directory,
|
|
||||||
query: input.query,
|
|
||||||
limit: input.limit,
|
|
||||||
kind: input.type ?? "all",
|
|
||||||
})
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
return found.map(
|
|
||||||
(item) =>
|
|
||||||
new Entry({
|
|
||||||
path: RelativePath.make(item.path),
|
|
||||||
uri: pathToFileURL(path.join(location.directory, item.path)).href,
|
|
||||||
type: item.type,
|
|
||||||
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(item.path),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
grep: Effect.fn("FileSystem.grep")(function* (input) {
|
|
||||||
return (yield* search
|
|
||||||
.search({
|
|
||||||
cwd: location.directory,
|
|
||||||
pattern: input.pattern,
|
|
||||||
glob: input.include ? [input.include] : undefined,
|
|
||||||
limit: input.limit,
|
|
||||||
})
|
|
||||||
.pipe(Effect.orDie)).items.map(
|
|
||||||
(item) =>
|
|
||||||
new GrepMatch({
|
|
||||||
path: RelativePath.make(item.path.text),
|
|
||||||
lines: item.lines.text,
|
|
||||||
line: item.line_number,
|
|
||||||
offset: item.absolute_offset,
|
|
||||||
submatches: item.submatches.map((submatch) => ({
|
|
||||||
text: submatch.match.text,
|
|
||||||
start: submatch.start,
|
|
||||||
end: submatch.end,
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|||||||
@@ -1,487 +0,0 @@
|
|||||||
import path from "path"
|
|
||||||
import { serviceUse } from "../effect/service-use"
|
|
||||||
import { FSUtil } from "../fs-util"
|
|
||||||
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
|
|
||||||
import type { PlatformError } from "effect/PlatformError"
|
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
|
||||||
|
|
||||||
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { NonNegativeInt } from "../schema"
|
|
||||||
import { which } from "../util/which"
|
|
||||||
import { LayerNode } from "../effect/layer-node"
|
|
||||||
import { httpClient } from "../effect/layer-node-platform"
|
|
||||||
|
|
||||||
const VERSION = "15.1.0"
|
|
||||||
const PLATFORM = {
|
|
||||||
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
|
|
||||||
"arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
|
|
||||||
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
|
|
||||||
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
|
|
||||||
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
|
|
||||||
"ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" },
|
|
||||||
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const TimeStats = Schema.Struct({
|
|
||||||
secs: NonNegativeInt,
|
|
||||||
nanos: NonNegativeInt,
|
|
||||||
human: Schema.String,
|
|
||||||
})
|
|
||||||
|
|
||||||
const Stats = Schema.Struct({
|
|
||||||
elapsed: TimeStats,
|
|
||||||
searches: NonNegativeInt,
|
|
||||||
searches_with_match: NonNegativeInt,
|
|
||||||
bytes_searched: NonNegativeInt,
|
|
||||||
bytes_printed: NonNegativeInt,
|
|
||||||
matched_lines: NonNegativeInt,
|
|
||||||
matches: NonNegativeInt,
|
|
||||||
})
|
|
||||||
|
|
||||||
const PathText = Schema.Struct({
|
|
||||||
text: Schema.String,
|
|
||||||
})
|
|
||||||
|
|
||||||
const Begin = Schema.Struct({
|
|
||||||
type: Schema.Literal("begin"),
|
|
||||||
data: Schema.Struct({
|
|
||||||
path: PathText,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const SearchMatch = Schema.Struct({
|
|
||||||
path: PathText,
|
|
||||||
lines: Schema.Struct({
|
|
||||||
text: Schema.String,
|
|
||||||
}),
|
|
||||||
line_number: NonNegativeInt,
|
|
||||||
absolute_offset: NonNegativeInt,
|
|
||||||
submatches: Schema.Array(
|
|
||||||
Schema.Struct({
|
|
||||||
match: Schema.Struct({
|
|
||||||
text: Schema.String,
|
|
||||||
}),
|
|
||||||
start: NonNegativeInt,
|
|
||||||
end: NonNegativeInt,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Match = Schema.Struct({
|
|
||||||
type: Schema.Literal("match"),
|
|
||||||
data: SearchMatch,
|
|
||||||
})
|
|
||||||
|
|
||||||
const End = Schema.Struct({
|
|
||||||
type: Schema.Literal("end"),
|
|
||||||
data: Schema.Struct({
|
|
||||||
path: PathText,
|
|
||||||
binary_offset: Schema.NullOr(NonNegativeInt),
|
|
||||||
stats: Stats,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const Summary = Schema.Struct({
|
|
||||||
type: Schema.Literal("summary"),
|
|
||||||
data: Schema.Struct({
|
|
||||||
elapsed_total: TimeStats,
|
|
||||||
stats: Stats,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const Result = Schema.Union([Begin, Match, End, Summary])
|
|
||||||
const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result))
|
|
||||||
|
|
||||||
export type Result = Schema.Schema.Type<typeof Result>
|
|
||||||
export type Match = Schema.Schema.Type<typeof Match>
|
|
||||||
export type Item = Match["data"]
|
|
||||||
export type Begin = Schema.Schema.Type<typeof Begin>
|
|
||||||
export type End = Schema.Schema.Type<typeof End>
|
|
||||||
export type Summary = Schema.Schema.Type<typeof Summary>
|
|
||||||
export type Row = Match["data"]
|
|
||||||
|
|
||||||
export interface SearchResult {
|
|
||||||
items: Item[]
|
|
||||||
partial: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FilesInput {
|
|
||||||
cwd: string
|
|
||||||
glob?: string[]
|
|
||||||
hidden?: boolean
|
|
||||||
follow?: boolean
|
|
||||||
maxDepth?: number
|
|
||||||
signal?: AbortSignal
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchInput {
|
|
||||||
cwd: string
|
|
||||||
pattern: string
|
|
||||||
glob?: string[]
|
|
||||||
limit?: number
|
|
||||||
follow?: boolean
|
|
||||||
file?: string[]
|
|
||||||
signal?: AbortSignal
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TreeInput {
|
|
||||||
cwd: string
|
|
||||||
limit?: number
|
|
||||||
signal?: AbortSignal
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly filepath: Effect.Effect<string, Error>
|
|
||||||
readonly files: (input: FilesInput) => Stream.Stream<string, PlatformError | Error>
|
|
||||||
readonly tree: (input: TreeInput) => Effect.Effect<string, PlatformError | Error>
|
|
||||||
readonly search: (input: SearchInput) => Effect.Effect<SearchResult, PlatformError | Error>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
|
|
||||||
|
|
||||||
export const use = serviceUse(Service)
|
|
||||||
|
|
||||||
function env() {
|
|
||||||
const env = Object.fromEntries(
|
|
||||||
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
|
||||||
)
|
|
||||||
delete env.RIPGREP_CONFIG_PATH
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
function aborted(signal?: AbortSignal) {
|
|
||||||
const err = signal?.reason
|
|
||||||
if (err instanceof Error) return err
|
|
||||||
const out = new Error("Aborted")
|
|
||||||
out.name = "AbortError"
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForAbort(signal?: AbortSignal) {
|
|
||||||
if (!signal) return Effect.never
|
|
||||||
if (signal.aborted) return Effect.fail(aborted(signal))
|
|
||||||
return Effect.callback<never, Error>((resume) => {
|
|
||||||
const onabort = () => resume(Effect.fail(aborted(signal)))
|
|
||||||
signal.addEventListener("abort", onabort, { once: true })
|
|
||||||
return Effect.sync(() => signal.removeEventListener("abort", onabort))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function error(stderr: string, code: number) {
|
|
||||||
const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`)
|
|
||||||
err.name = "RipgrepError"
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
function clean(file: string) {
|
|
||||||
return path.normalize(file.replace(/^\.[\\/]/, ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
function row(data: Row): Row {
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
path: {
|
|
||||||
...data.path,
|
|
||||||
text: clean(data.path.text),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parse(line: string) {
|
|
||||||
return decodeResult(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause })))
|
|
||||||
}
|
|
||||||
|
|
||||||
function fail(queue: Queue.Queue<string, PlatformError | Error | Cause.Done>, err: PlatformError | Error) {
|
|
||||||
Queue.failCauseUnsafe(queue, Cause.fail(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
function filesArgs(input: FilesInput) {
|
|
||||||
const args = ["--no-config", "--files", "--glob=!.git/*"]
|
|
||||||
if (input.follow) args.push("--follow")
|
|
||||||
if (input.hidden !== false) args.push("--hidden")
|
|
||||||
if (input.hidden === false) args.push("--glob=!.*")
|
|
||||||
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
|
|
||||||
if (input.glob) {
|
|
||||||
for (const glob of input.glob) args.push(`--glob=${glob}`)
|
|
||||||
}
|
|
||||||
args.push(".")
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchArgs(input: SearchInput) {
|
|
||||||
const args = ["--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"]
|
|
||||||
if (input.follow) args.push("--follow")
|
|
||||||
if (input.glob) {
|
|
||||||
for (const glob of input.glob) args.push(`--glob=${glob}`)
|
|
||||||
}
|
|
||||||
if (input.limit) args.push(`--max-count=${input.limit}`)
|
|
||||||
args.push("--", input.pattern, ...(input.file ?? ["."]))
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
|
|
||||||
function raceAbort<A, E, R>(effect: Effect.Effect<A, E, R>, signal?: AbortSignal) {
|
|
||||||
return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect
|
|
||||||
}
|
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpawner | HttpClient.HttpClient> =
|
|
||||||
Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fs = yield* FSUtil.Service
|
|
||||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
|
||||||
const spawner = yield* ChildProcessSpawner
|
|
||||||
|
|
||||||
const run = Effect.fnUntraced(function* (command: string, args: string[], opts?: { cwd?: string }) {
|
|
||||||
const handle = yield* spawner.spawn(
|
|
||||||
ChildProcess.make(command, args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
|
|
||||||
)
|
|
||||||
const [stdout, stderr, code] = yield* Effect.all(
|
|
||||||
[
|
|
||||||
Stream.mkString(Stream.decodeText(handle.stdout)),
|
|
||||||
Stream.mkString(Stream.decodeText(handle.stderr)),
|
|
||||||
handle.exitCode,
|
|
||||||
],
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
)
|
|
||||||
return { stdout, stderr, code }
|
|
||||||
}, Effect.scoped)
|
|
||||||
|
|
||||||
const extract = Effect.fnUntraced(function* (
|
|
||||||
archive: string,
|
|
||||||
config: (typeof PLATFORM)[keyof typeof PLATFORM],
|
|
||||||
target: string,
|
|
||||||
) {
|
|
||||||
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
|
|
||||||
|
|
||||||
if (config.extension === "zip") {
|
|
||||||
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
|
|
||||||
const result = yield* run(shell, [
|
|
||||||
"-NoProfile",
|
|
||||||
"-NonInteractive",
|
|
||||||
"-Command",
|
|
||||||
`$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`,
|
|
||||||
])
|
|
||||||
if (result.code !== 0) {
|
|
||||||
return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.extension === "tar.gz") {
|
|
||||||
const result = yield* run("tar", ["-xzf", archive, "-C", dir])
|
|
||||||
if (result.code !== 0) {
|
|
||||||
return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const extracted = path.join(
|
|
||||||
dir,
|
|
||||||
`ripgrep-${VERSION}-${config.platform}`,
|
|
||||||
process.platform === "win32" ? "rg.exe" : "rg",
|
|
||||||
)
|
|
||||||
if (!(yield* fs.isFile(extracted))) {
|
|
||||||
return yield* Effect.fail(new Error(`ripgrep archive did not contain executable: ${extracted}`))
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* fs.copyFile(extracted, target)
|
|
||||||
if (process.platform === "win32") return
|
|
||||||
yield* fs.chmod(target, 0o755)
|
|
||||||
}, Effect.scoped)
|
|
||||||
|
|
||||||
const filepath = yield* Effect.cached(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
|
|
||||||
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
|
|
||||||
|
|
||||||
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
|
|
||||||
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
|
|
||||||
|
|
||||||
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
|
||||||
const config = PLATFORM[platformKey]
|
|
||||||
if (!config) {
|
|
||||||
return yield* Effect.fail(new Error(`unsupported platform for ripgrep: ${platformKey}`))
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
|
|
||||||
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
|
|
||||||
const archive = path.join(Global.Path.bin, filename)
|
|
||||||
|
|
||||||
yield* Effect.logInfo("downloading ripgrep", { url })
|
|
||||||
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
|
|
||||||
|
|
||||||
const bytes = yield* HttpClientRequest.get(url).pipe(
|
|
||||||
http.execute,
|
|
||||||
Effect.flatMap((response) => response.arrayBuffer),
|
|
||||||
Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
|
|
||||||
)
|
|
||||||
if (bytes.byteLength === 0) {
|
|
||||||
return yield* Effect.fail(new Error(`failed to download ripgrep from ${url}`))
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* fs.writeWithDirs(archive, new Uint8Array(bytes))
|
|
||||||
yield* extract(archive, config, target)
|
|
||||||
yield* fs.remove(archive, { force: true }).pipe(Effect.ignore)
|
|
||||||
return target
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const check = Effect.fnUntraced(function* (cwd: string) {
|
|
||||||
if (yield* fs.isDir(cwd).pipe(Effect.orDie)) return
|
|
||||||
return yield* Effect.fail(
|
|
||||||
Object.assign(new Error(`No such file or directory: '${cwd}'`), {
|
|
||||||
code: "ENOENT",
|
|
||||||
errno: -2,
|
|
||||||
path: cwd,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const command = Effect.fnUntraced(function* (cwd: string, args: string[]) {
|
|
||||||
const binary = yield* filepath
|
|
||||||
return ChildProcess.make(binary, args, {
|
|
||||||
cwd,
|
|
||||||
env: env(),
|
|
||||||
extendEnv: true,
|
|
||||||
stdin: "ignore",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const files: Interface["files"] = (input) =>
|
|
||||||
Stream.callback<string, PlatformError | Error>((queue) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* Effect.forkScoped(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* check(input.cwd)
|
|
||||||
const handle = yield* spawner.spawn(yield* command(input.cwd, filesArgs(input)))
|
|
||||||
const stderr = yield* Stream.mkString(Stream.decodeText(handle.stderr)).pipe(Effect.forkScoped)
|
|
||||||
const stdout = yield* Stream.decodeText(handle.stdout).pipe(
|
|
||||||
Stream.splitLines,
|
|
||||||
Stream.filter((line) => line.length > 0),
|
|
||||||
Stream.runForEach((line) => Effect.sync(() => Queue.offerUnsafe(queue, clean(line)))),
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
|
||||||
const code = yield* raceAbort(handle.exitCode, input.signal)
|
|
||||||
yield* Fiber.join(stdout)
|
|
||||||
if (code === 0 || code === 1) {
|
|
||||||
Queue.endUnsafe(queue)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fail(queue, error(yield* Fiber.join(stderr), code))
|
|
||||||
}).pipe(
|
|
||||||
Effect.catch((err) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
fail(queue, err)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
|
|
||||||
yield* check(input.cwd)
|
|
||||||
|
|
||||||
const program = Effect.scoped(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const handle = yield* spawner.spawn(yield* command(input.cwd, searchArgs(input)))
|
|
||||||
|
|
||||||
const [items, stderr, code] = yield* Effect.all(
|
|
||||||
[
|
|
||||||
Stream.decodeText(handle.stdout).pipe(
|
|
||||||
Stream.splitLines,
|
|
||||||
Stream.filter((line) => line.length > 0),
|
|
||||||
Stream.mapEffect(parse),
|
|
||||||
Stream.filter((item): item is Match => item.type === "match"),
|
|
||||||
Stream.map((item) => row(item.data)),
|
|
||||||
Stream.runCollect,
|
|
||||||
Effect.map((chunk) => [...chunk]),
|
|
||||||
),
|
|
||||||
Stream.mkString(Stream.decodeText(handle.stderr)),
|
|
||||||
handle.exitCode,
|
|
||||||
],
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
)
|
|
||||||
|
|
||||||
if (code !== 0 && code !== 1 && code !== 2) {
|
|
||||||
return yield* Effect.fail(error(stderr, code))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: code === 1 ? [] : items,
|
|
||||||
partial: code === 2,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return yield* raceAbort(program, input.signal)
|
|
||||||
})
|
|
||||||
|
|
||||||
const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
|
|
||||||
yield* Effect.logInfo("tree", input)
|
|
||||||
const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
|
|
||||||
|
|
||||||
interface Node {
|
|
||||||
name: string
|
|
||||||
children: Map<string, Node>
|
|
||||||
}
|
|
||||||
|
|
||||||
function child(node: Node, name: string) {
|
|
||||||
const item = node.children.get(name)
|
|
||||||
if (item) return item
|
|
||||||
const next = { name, children: new Map() }
|
|
||||||
node.children.set(name, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
function count(node: Node): number {
|
|
||||||
return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const root: Node = { name: "", children: new Map() }
|
|
||||||
for (const file of list) {
|
|
||||||
if (file.includes(".opencode")) continue
|
|
||||||
const parts = file.split(path.sep)
|
|
||||||
if (parts.length < 2) continue
|
|
||||||
let node = root
|
|
||||||
for (const part of parts.slice(0, -1)) {
|
|
||||||
node = child(node, part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = count(root)
|
|
||||||
const limit = input.limit ?? total
|
|
||||||
const lines: string[] = []
|
|
||||||
const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values())
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.map((node) => ({ node, path: node.name }))
|
|
||||||
|
|
||||||
let used = 0
|
|
||||||
for (let i = 0; i < queue.length && used < limit; i++) {
|
|
||||||
const item = queue[i]
|
|
||||||
lines.push(item.path)
|
|
||||||
used++
|
|
||||||
queue.push(
|
|
||||||
...Array.from(item.node.children.values())
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.map((node) => ({ node, path: `${item.path}/${node.name}` })),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (total > used) lines.push(`[${total - used} truncated]`)
|
|
||||||
return lines.join("\n")
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ filepath, files, tree, search })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(FetchHttpClient.layer),
|
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
|
||||||
)
|
|
||||||
export const node = LayerNode.make(layer, [FSUtil.node, CrossSpawnSpawner.node, httpClient])
|
|
||||||
|
|
||||||
export * as Ripgrep from "./ripgrep"
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Schema } from "effect"
|
||||||
|
import { NonNegativeInt, PositiveInt, RelativePath } from "../schema"
|
||||||
|
|
||||||
|
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
|
||||||
|
path: RelativePath,
|
||||||
|
type: Schema.Literals(["file", "directory"]),
|
||||||
|
mime: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export const Submatch = Schema.Struct({
|
||||||
|
text: Schema.String,
|
||||||
|
start: NonNegativeInt,
|
||||||
|
end: NonNegativeInt,
|
||||||
|
})
|
||||||
|
export type Submatch = typeof Submatch.Type
|
||||||
|
|
||||||
|
export class Match extends Schema.Class<Match>("FileSystem.Match")({
|
||||||
|
entry: Entry,
|
||||||
|
line: PositiveInt,
|
||||||
|
offset: NonNegativeInt,
|
||||||
|
text: Schema.String,
|
||||||
|
submatches: Schema.Array(Submatch),
|
||||||
|
}) {}
|
||||||
@@ -1,567 +1,230 @@
|
|||||||
|
export * as FileSystemSearch from "./search"
|
||||||
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Context, Deferred, Effect, Layer, Option, Stream } from "effect"
|
import { Context, Effect, Fiber, Layer, Scope } from "effect"
|
||||||
import type { PlatformError } from "effect/PlatformError"
|
|
||||||
import { FSUtil } from "../fs-util"
|
|
||||||
import { Glob } from "../util/glob"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { serviceUse } from "../effect/service-use"
|
|
||||||
import { makeRuntime } from "../effect/runtime"
|
|
||||||
import { Fff } from "#fff"
|
import { Fff } from "#fff"
|
||||||
import { Ripgrep } from "./ripgrep"
|
import fuzzysort from "fuzzysort"
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { FileSystem } from "../filesystem"
|
||||||
|
import { FSUtil } from "../fs-util"
|
||||||
const root = path.join(Global.Path.cache, "fff")
|
import { Location } from "../location"
|
||||||
|
import { Ripgrep } from "../ripgrep"
|
||||||
export type Item = Ripgrep.Item
|
import { RelativePath } from "../schema"
|
||||||
export type SearchError = PlatformError | globalThis.Error
|
|
||||||
|
|
||||||
export interface Result {
|
|
||||||
readonly items: Item[]
|
|
||||||
readonly partial: boolean
|
|
||||||
readonly hasNextPage: boolean
|
|
||||||
readonly engine: "fff" | "ripgrep"
|
|
||||||
readonly regexFallbackError?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileInput {
|
|
||||||
readonly cwd: string
|
|
||||||
readonly query: string
|
|
||||||
readonly limit?: number
|
|
||||||
readonly current?: string
|
|
||||||
readonly kind?: "file" | "directory" | "all"
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileResult {
|
|
||||||
readonly path: string
|
|
||||||
readonly type: "file" | "directory"
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GlobInput {
|
|
||||||
readonly cwd: string
|
|
||||||
readonly pattern: string
|
|
||||||
readonly limit?: number
|
|
||||||
readonly signal?: AbortSignal
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Query {
|
|
||||||
readonly dir: string
|
|
||||||
readonly text: string
|
|
||||||
readonly files: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// A created picker plus its cached scan-readiness gate. The picker is created
|
|
||||||
// (and its native background scan kicked off) eagerly; `ready` is only awaited
|
|
||||||
// when the picker is actually used.
|
|
||||||
interface Picker {
|
|
||||||
readonly pick: Fff.Picker
|
|
||||||
readonly ready: Effect.Effect<void, Error>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
readonly pick: Map<string, Picker>
|
|
||||||
readonly wait: Map<string, Deferred.Deferred<Picker, Error>>
|
|
||||||
readonly recent: Query[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly files: Ripgrep.Interface["files"]
|
readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
|
||||||
readonly tree: Ripgrep.Interface["tree"]
|
readonly glob: (input: FileSystem.GlobInput) => Effect.Effect<readonly FileSystem.Entry[]>
|
||||||
readonly search: (input: Ripgrep.SearchInput) => Effect.Effect<Result, SearchError>
|
readonly grep: (input: FileSystem.GrepInput) => Effect.Effect<readonly FileSystem.Match[]>
|
||||||
readonly file: (input: FileInput) => Effect.Effect<readonly FileResult[], SearchError>
|
|
||||||
readonly glob: (input: GlobInput) => Effect.Effect<{ files: string[]; truncated: boolean }, SearchError>
|
|
||||||
readonly open: (input: { cwd?: string; file: string }) => Effect.Effect<void, SearchError>
|
|
||||||
readonly warm: (cwd: string) => Effect.Effect<void>
|
|
||||||
// Destroy the picker for a directory and drop its cached state. Called when a
|
|
||||||
// directory's instance is disposed so fff's native watcher thread is torn
|
|
||||||
// down instead of leaking until process exit.
|
|
||||||
readonly release: (cwd: string) => Effect.Effect<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Search") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem/Search") {}
|
||||||
|
|
||||||
export const use = serviceUse(Service)
|
export const ripgrepLayer = Layer.effect(
|
||||||
|
|
||||||
function key(dir: string) {
|
|
||||||
return Buffer.from(dir).toString("base64url")
|
|
||||||
}
|
|
||||||
|
|
||||||
function fffSync<A>(action: string, run: () => A) {
|
|
||||||
return Effect.try({
|
|
||||||
try: run,
|
|
||||||
catch: (cause) => new Error(`fff ${action} failed`, { cause }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(text: string) {
|
|
||||||
return text.replaceAll("\\", "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// fff supports glob narrowing for any search out of the box
|
|
||||||
function fffGlobbedQuery(query: string, glob?: string | string[]) {
|
|
||||||
if (query && glob) {
|
|
||||||
const resolvedGlob = Array.isArray(glob) ? glob.join(" ") : glob
|
|
||||||
return `${resolvedGlob} ${query}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return query ?? glob
|
|
||||||
}
|
|
||||||
|
|
||||||
function remember(state: State, dir: string, text: string, files: string[]) {
|
|
||||||
if (!files.length) return
|
|
||||||
const next = Array.from(new Set(files.map(FSUtil.resolve))).slice(0, 64)
|
|
||||||
if (!next.length) return
|
|
||||||
const idx = state.recent.findIndex((item) => item.dir === dir && item.text === text)
|
|
||||||
if (idx >= 0) state.recent.splice(idx, 1)
|
|
||||||
state.recent.unshift({ dir, text, files: next })
|
|
||||||
if (state.recent.length > 32) state.recent.length = 32
|
|
||||||
}
|
|
||||||
|
|
||||||
function item(hit: Fff.Hit): Item {
|
|
||||||
const line = Buffer.from(hit.lineContent)
|
|
||||||
return {
|
|
||||||
path: { text: normalize(hit.relativePath) },
|
|
||||||
lines: { text: hit.lineContent },
|
|
||||||
line_number: hit.lineNumber,
|
|
||||||
absolute_offset: hit.byteOffset,
|
|
||||||
submatches: hit.matchRanges
|
|
||||||
.map(([start, end]) => {
|
|
||||||
const text = line.subarray(start, end).toString("utf8")
|
|
||||||
if (!text) return undefined
|
|
||||||
return {
|
|
||||||
match: { text },
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((row): row is Item["submatches"][number] => Boolean(row)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectPaths<T>(
|
|
||||||
items: T[],
|
|
||||||
scores: Array<{ total: number }>,
|
|
||||||
toResult: (item: T) => FileResult,
|
|
||||||
): FileResult[] {
|
|
||||||
const rows = items.flatMap((item, index): Array<FileResult & { score: number }> => {
|
|
||||||
const result = toResult(item)
|
|
||||||
if (!result.path) return []
|
|
||||||
return [{ ...result, score: scores[index]?.total ?? 0 }]
|
|
||||||
})
|
|
||||||
rows.sort(
|
|
||||||
(a, b) => b.score - a.score || a.path.length - b.path.length || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
const seen = new Set<string>()
|
|
||||||
return rows.flatMap((item) => {
|
|
||||||
if (seen.has(item.path)) return []
|
|
||||||
seen.add(item.path)
|
|
||||||
return [{ path: item.path, type: item.type }]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchFff(
|
|
||||||
pick: Fff.Picker,
|
|
||||||
kind: "file" | "directory" | "all",
|
|
||||||
query: string,
|
|
||||||
opts: { currentFile?: string; pageIndex?: number; pageSize?: number },
|
|
||||||
): Fff.Result<FileResult[]> {
|
|
||||||
if (kind === "directory") {
|
|
||||||
const out = pick.directorySearch(query, opts)
|
|
||||||
if (!out.ok) return out
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
value: collectPaths(out.value.items, out.value.scores, (entry) => ({
|
|
||||||
path: normalize(entry.relativePath),
|
|
||||||
type: "directory",
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (kind === "all") {
|
|
||||||
const out = pick.mixedSearch(query, opts)
|
|
||||||
if (!out.ok) return out
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
value: collectPaths(out.value.items, out.value.scores, (entry) => ({
|
|
||||||
path: normalize(entry.item.relativePath),
|
|
||||||
type: entry.type,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const out = pick.fileSearch(query, opts)
|
|
||||||
if (!out.ok) return out
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
value: collectPaths(out.value.items, out.value.scores, (entry) => ({
|
|
||||||
path: normalize(entry.relativePath),
|
|
||||||
type: "file",
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service> = Layer.effect(
|
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const rg = yield* Ripgrep.Service
|
const location = yield* Location.Service
|
||||||
|
const ripgrep = yield* Ripgrep.Service
|
||||||
const state: State = {
|
const scope = yield* Scope.Scope
|
||||||
pick: new Map<string, Picker>(),
|
const state = {
|
||||||
wait: new Map<string, Deferred.Deferred<Picker, Error>>(),
|
files: [] as string[],
|
||||||
recent: [] as Query[],
|
directories: [] as string[],
|
||||||
|
scan: undefined as Fiber.Fiber<void, never> | undefined,
|
||||||
}
|
}
|
||||||
|
state.scan = yield* ripgrep.find({ cwd: location.directory, pattern: "*", limit: 100_000 }).pipe(
|
||||||
yield* fs.ensureDir(root).pipe(Effect.ignore)
|
Effect.tap((result) =>
|
||||||
yield* Effect.addFinalizer(() =>
|
Effect.sync(() => {
|
||||||
Effect.forEach(
|
state.files = result.map((item) => item.path)
|
||||||
state.pick.values(),
|
state.directories = Array.from(
|
||||||
(entry) => fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore),
|
new Set(
|
||||||
{ discard: true },
|
state.files.flatMap((file) => {
|
||||||
),
|
const parts = file.split("/")
|
||||||
)
|
return parts.slice(0, -1).map((_, index) => parts.slice(0, index + 1).join("/") + path.sep)
|
||||||
|
}),
|
||||||
const rip = Effect.fn("Search.rip")(function* (input: Ripgrep.SearchInput) {
|
|
||||||
const out = yield* rg.search(input)
|
|
||||||
return {
|
|
||||||
items: out.items,
|
|
||||||
partial: out.partial,
|
|
||||||
hasNextPage: false,
|
|
||||||
engine: "ripgrep" as const,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Lazy, shared scan-wait for a picker. Preserves the original behavior: if
|
|
||||||
// the scan does not finish within the budget the picker is destroyed and
|
|
||||||
// dropped from the cache so callers fall back to ripgrep (and the next
|
|
||||||
// request recreates a fresh picker).
|
|
||||||
const scanReady = (dir: string, pick: Fff.Picker) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const scanned = yield* Effect.tryPromise({
|
|
||||||
try: () => pick.waitForScan(5_000),
|
|
||||||
catch: (cause) => new Error("fff waitForScan failed", { cause }),
|
|
||||||
})
|
|
||||||
if (!scanned.ok || !scanned.value) {
|
|
||||||
yield* fffSync("destroy picker", () => pick.destroy()).pipe(Effect.ignore)
|
|
||||||
state.pick.delete(dir)
|
|
||||||
yield* Effect.logWarning("fff scan not ready", { dir })
|
|
||||||
return yield* Effect.fail(new Error(scanned.ok ? "fff scan timed out" : scanned.error))
|
|
||||||
}
|
|
||||||
|
|
||||||
const git = yield* fffSync("refresh git status", () => pick.refreshGitStatus())
|
|
||||||
if (!git.ok) {
|
|
||||||
yield* Effect.logWarning("fff git refresh failed", { dir, error: git.error })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create (or return) the picker for a directory. Creation is synchronous
|
|
||||||
// and does not await the scan; the native background scan starts as soon as
|
|
||||||
// the picker exists. The `wait` gate dedupes concurrent creation.
|
|
||||||
const acquire = Effect.fn("Search.acquire")(function* (cwd: string) {
|
|
||||||
const dir = FSUtil.resolve(cwd)
|
|
||||||
const existing = state.pick.get(dir)
|
|
||||||
if (existing) return existing
|
|
||||||
|
|
||||||
const pending = state.wait.get(dir)
|
|
||||||
if (pending) return yield* Deferred.await(pending)
|
|
||||||
|
|
||||||
const available = yield* fffSync("check availability", () => Fff.available()).pipe(
|
|
||||||
Effect.catch((error) => Effect.logWarning("fff availability check failed", { error }).pipe(Effect.as(false))),
|
|
||||||
)
|
|
||||||
if (!available) return undefined
|
|
||||||
|
|
||||||
const gate = yield* Deferred.make<Picker, Error>()
|
|
||||||
state.wait.set(dir, gate)
|
|
||||||
return yield* Effect.gen(function* () {
|
|
||||||
const id = key(dir)
|
|
||||||
const isFirstPicker = state.pick.size === 0
|
|
||||||
const made = yield* fffSync("create picker", () =>
|
|
||||||
Fff.create({
|
|
||||||
basePath: dir,
|
|
||||||
frecencyDbPath: path.join(root, `${id}.frecency.mdb`),
|
|
||||||
historyDbPath: path.join(root, `${id}.history.mdb`),
|
|
||||||
aiMode: true,
|
|
||||||
// only the first toolcall picker can accumulate resources to index
|
|
||||||
// home directory, if the user specifically opened opencode at the
|
|
||||||
// $HOME level or asked it to search there on purpose, otherwise fallback
|
|
||||||
enableHomeDirScanning: isFirstPicker,
|
|
||||||
// on unix system it is 99.9% that you do not need to search for the
|
|
||||||
// content at the / so make fff fail creation and fallback to rg
|
|
||||||
enableFsRootScanning: isFirstPicker && process.platform === "win32",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (!made.ok) {
|
|
||||||
yield* Effect.logWarning("fff init failed", { dir, error: made.error })
|
|
||||||
const err = new Error(made.error)
|
|
||||||
yield* Deferred.fail(gate, err)
|
|
||||||
return yield* Effect.fail(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pick = made.value
|
|
||||||
const entry: Picker = { pick, ready: yield* Effect.cached(scanReady(dir, pick)) }
|
|
||||||
state.pick.set(dir, entry)
|
|
||||||
yield* Deferred.succeed(gate, entry)
|
|
||||||
return entry
|
|
||||||
}).pipe(
|
|
||||||
Effect.ensuring(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (state.wait.get(dir) === gate) state.wait.delete(dir)
|
|
||||||
yield* Deferred.fail(gate, new Error("fff init interrupted")).pipe(Effect.ignore)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Resolve a usable, scanned picker for a directory, or undefined when fff is
|
|
||||||
// unavailable or the scan did not become ready.
|
|
||||||
const picker = Effect.fn("Search.picker")(function* (cwd: string) {
|
|
||||||
const entry = yield* acquire(cwd).pipe(Effect.catch(() => Effect.succeed<Picker | undefined>(undefined)))
|
|
||||||
if (!entry) return undefined
|
|
||||||
const ready = yield* entry.ready.pipe(
|
|
||||||
Effect.as(true),
|
|
||||||
Effect.catch(() => Effect.succeed(false)),
|
|
||||||
)
|
|
||||||
if (!ready) return undefined
|
|
||||||
return entry.pick
|
|
||||||
})
|
|
||||||
|
|
||||||
const files: Interface["files"] = (input) => rg.files(input)
|
|
||||||
const tree: Interface["tree"] = (input) => rg.tree(input)
|
|
||||||
|
|
||||||
// in 99% of use cases user that is opened opencode at certain directory will
|
|
||||||
// conduct a file search in this direcotry, it could be switched later but
|
|
||||||
// mostly always we will need a file picker for cwd
|
|
||||||
// so synchronously start FFF scan for a cwd so it is ready before first toolcall generated
|
|
||||||
const warm: Interface["warm"] = Effect.fn("Search.warm")(function* (cwd) {
|
|
||||||
yield* acquire(cwd).pipe(Effect.ignore)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Tear down the picker for a directory. fff pickers own a native background
|
|
||||||
// watcher thread that otherwise lives until the runtime scope closes (i.e.
|
|
||||||
// process exit), so disposing the instance that warmed it must destroy it
|
|
||||||
// here or the thread leaks against a directory that may already be gone.
|
|
||||||
const release: Interface["release"] = Effect.fn("Search.release")(function* (cwd) {
|
|
||||||
const dir = FSUtil.resolve(cwd)
|
|
||||||
|
|
||||||
const pending = state.wait.get(dir)
|
|
||||||
if (pending) {
|
|
||||||
state.wait.delete(dir)
|
|
||||||
yield* Deferred.fail(pending, new Error("fff picker released")).pipe(Effect.ignore)
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = state.pick.get(dir)
|
|
||||||
if (entry) {
|
|
||||||
state.pick.delete(dir)
|
|
||||||
yield* fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore)
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = state.recent.filter((item) => item.dir !== dir)
|
|
||||||
state.recent.splice(0, state.recent.length, ...remaining)
|
|
||||||
})
|
|
||||||
|
|
||||||
const file: Interface["file"] = Effect.fn("Search.file")(function* (input) {
|
|
||||||
const query = input.query.trim()
|
|
||||||
const kind = input.kind ?? "file"
|
|
||||||
|
|
||||||
const entry = yield* acquire(input.cwd)
|
|
||||||
if (!entry) return yield* Effect.fail(new Error("fff is unavailable"))
|
|
||||||
yield* entry.ready
|
|
||||||
const dir = FSUtil.resolve(input.cwd)
|
|
||||||
const limit = input.limit ?? 100
|
|
||||||
const fffResult = yield* fffSync(`${kind} search`, () =>
|
|
||||||
searchFff(entry.pick, kind, query, {
|
|
||||||
pageIndex: 0,
|
|
||||||
currentFile: input.current, // supports both relative and absolute (relative preferred)
|
|
||||||
pageSize: limit,
|
|
||||||
}),
|
|
||||||
).pipe(
|
|
||||||
Effect.catch((error) =>
|
|
||||||
Effect.logWarning(`fff ${kind} search failed`, { dir, query, error }).pipe(
|
|
||||||
Effect.andThen(Effect.fail(error)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!fffResult.ok) {
|
|
||||||
yield* Effect.logWarning(`fff ${kind} search failed`, { dir, query, error: fffResult.error })
|
|
||||||
return yield* Effect.fail(new Error(fffResult.error))
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = fffResult.value
|
|
||||||
remember(
|
|
||||||
state,
|
|
||||||
dir,
|
|
||||||
query,
|
|
||||||
rows.map((row) => path.join(dir, row.path)),
|
|
||||||
)
|
|
||||||
return rows.slice(0, limit)
|
|
||||||
})
|
|
||||||
|
|
||||||
const search: Interface["search"] = Effect.fn("Search.search")(function* (input) {
|
|
||||||
input.signal?.throwIfAborted()
|
|
||||||
if (input.file?.length) return yield* rip(input)
|
|
||||||
|
|
||||||
const pick = yield* picker(input.cwd)
|
|
||||||
if (!pick) return yield* rip(input)
|
|
||||||
|
|
||||||
const dir = FSUtil.resolve(input.cwd)
|
|
||||||
const limit = input.limit ?? 100
|
|
||||||
|
|
||||||
const fffGrep = yield* fffSync("grep", () =>
|
|
||||||
pick.grep(fffGlobbedQuery(input.pattern, input.glob), {
|
|
||||||
mode: "regex",
|
|
||||||
pageSize: limit,
|
|
||||||
timeBudgetMs: 1_500,
|
|
||||||
}),
|
|
||||||
).pipe(
|
|
||||||
Effect.catch((error) =>
|
|
||||||
Effect.logWarning("fff grep failed", { dir, pattern: input.pattern, error }).pipe(
|
|
||||||
Effect.as<Fff.Result<Fff.Grep> | undefined>(undefined),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!fffGrep) return yield* rip(input)
|
|
||||||
if (!fffGrep.ok) {
|
|
||||||
yield* Effect.logWarning("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error })
|
|
||||||
return yield* rip(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows: Item[] = fffGrep.value.items.map(item)
|
|
||||||
const regexFallbackError = fffGrep.value.regexFallbackError
|
|
||||||
|
|
||||||
remember(state, dir, input.pattern, Array.from(new Set(rows.map((row) => path.join(dir, row.path.text)))))
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: rows,
|
|
||||||
partial: false,
|
|
||||||
hasNextPage: !!fffGrep.value.nextCursor,
|
|
||||||
engine: "fff" as const,
|
|
||||||
regexFallbackError,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const glob: Interface["glob"] = Effect.fn("Search.glob")(function* (input) {
|
|
||||||
input.signal?.throwIfAborted()
|
|
||||||
|
|
||||||
const dir = FSUtil.resolve(input.cwd)
|
|
||||||
const limit = input.limit ?? 100
|
|
||||||
const pick = yield* picker(dir)
|
|
||||||
|
|
||||||
if (pick) {
|
|
||||||
const fffGlob = yield* fffSync("glob file search", () =>
|
|
||||||
pick.glob(normalize(input.pattern), {
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: limit,
|
|
||||||
}),
|
|
||||||
).pipe(
|
|
||||||
Effect.catch((error) =>
|
|
||||||
Effect.logWarning("fff glob failed", { dir, pattern: input.pattern, error }).pipe(
|
|
||||||
Effect.as<Fff.Result<Fff.Search> | undefined>(undefined),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (fffGlob?.ok) {
|
|
||||||
const rows: string[] = Array.from(new Set(fffGlob.value.items.map((item) => normalize(item.relativePath))))
|
|
||||||
|
|
||||||
remember(
|
|
||||||
state,
|
|
||||||
dir,
|
|
||||||
input.pattern,
|
|
||||||
rows.map((row) => path.join(dir, row)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
|
||||||
files: rows.slice(0, limit).map((row) => path.join(dir, row)),
|
|
||||||
truncated: fffGlob.value.totalMatched > rows.length,
|
|
||||||
}
|
|
||||||
} else if (fffGlob) {
|
|
||||||
yield* Effect.logWarning("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error })
|
|
||||||
// fall through to the fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = yield* rg.files({ cwd: dir, glob: [input.pattern], signal: input.signal }).pipe(
|
|
||||||
Stream.take(limit + 1),
|
|
||||||
Stream.runCollect,
|
|
||||||
Effect.map((chunk) => [...chunk]),
|
|
||||||
)
|
|
||||||
const truncated = rows.length > limit
|
|
||||||
if (truncated) rows.length = limit
|
|
||||||
|
|
||||||
const output = yield* Effect.forEach(
|
|
||||||
rows,
|
|
||||||
Effect.fnUntraced(function* (file) {
|
|
||||||
const full = path.join(dir, file)
|
|
||||||
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
const time =
|
|
||||||
info?.mtime.pipe(
|
|
||||||
Option.map((item) => item.getTime()),
|
|
||||||
Option.getOrElse(() => 0),
|
|
||||||
) ?? 0
|
|
||||||
return { file: full, time }
|
|
||||||
}),
|
}),
|
||||||
{ concurrency: 16 },
|
),
|
||||||
)
|
Effect.orDie,
|
||||||
output.sort((a, b) => b.time - a.time)
|
Effect.asVoid,
|
||||||
return {
|
Effect.forkIn(scope),
|
||||||
files: output.map((item) => item.file),
|
)
|
||||||
truncated,
|
return Service.of({
|
||||||
}
|
glob: (input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = path.resolve(location.directory, input.path ?? ".")
|
||||||
|
const info = yield* fs.stat(target).pipe(Effect.orDie)
|
||||||
|
const cwd = info.type === "File" ? path.dirname(target) : target
|
||||||
|
return yield* ripgrep
|
||||||
|
.glob({
|
||||||
|
cwd,
|
||||||
|
pattern: input.pattern,
|
||||||
|
limit: input.limit ?? Number.MAX_SAFE_INTEGER,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
Effect.map((result) =>
|
||||||
|
result.map(
|
||||||
|
(entry) =>
|
||||||
|
new FileSystem.Entry({
|
||||||
|
...entry,
|
||||||
|
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.orDie,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
grep: (input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = path.resolve(location.directory, input.path ?? ".")
|
||||||
|
const info = yield* fs.stat(target).pipe(Effect.orDie)
|
||||||
|
const cwd = info.type === "File" ? path.dirname(target) : target
|
||||||
|
return yield* ripgrep
|
||||||
|
.grep({
|
||||||
|
cwd,
|
||||||
|
pattern: input.pattern,
|
||||||
|
file: info.type === "File" ? path.basename(target) : undefined,
|
||||||
|
include: input.include,
|
||||||
|
limit: input.limit ?? Number.MAX_SAFE_INTEGER,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
Effect.map((result) =>
|
||||||
|
result.map(
|
||||||
|
(match) =>
|
||||||
|
new FileSystem.Match({
|
||||||
|
...match,
|
||||||
|
entry: new FileSystem.Entry({
|
||||||
|
...match.entry,
|
||||||
|
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.orDie,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
find: (input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (input.query) yield* Fiber.join(state.scan!)
|
||||||
|
const items =
|
||||||
|
input.type === "file"
|
||||||
|
? state.files
|
||||||
|
: input.type === "directory"
|
||||||
|
? state.directories
|
||||||
|
: [...state.files, ...state.directories]
|
||||||
|
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
||||||
|
const relative = item.target
|
||||||
|
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||||
|
const clean = type === "directory" ? relative.slice(0, -path.sep.length) : relative
|
||||||
|
const absolute = path.resolve(location.directory, clean)
|
||||||
|
return new FileSystem.Entry({
|
||||||
|
path: RelativePath.make(relative),
|
||||||
|
type,
|
||||||
|
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const open: Interface["open"] = Effect.fn("Search.open")(function* (input) {
|
|
||||||
const file = input.cwd
|
|
||||||
? FSUtil.resolve(path.isAbsolute(input.file) ? input.file : path.join(input.cwd, input.file))
|
|
||||||
: FSUtil.resolve(input.file)
|
|
||||||
const idx = state.recent.findIndex((item) => item.files.includes(file))
|
|
||||||
if (idx < 0) return
|
|
||||||
|
|
||||||
const row = state.recent[idx]
|
|
||||||
state.recent.splice(idx, 1)
|
|
||||||
const entry = state.pick.get(row.dir)
|
|
||||||
if (!entry) return
|
|
||||||
|
|
||||||
const out = yield* fffSync("track query", () => entry.pick.trackQuery(row.text, file)).pipe(
|
|
||||||
Effect.catch((error) =>
|
|
||||||
Effect.logWarning("fff track query failed", { dir: row.dir, query: row.text, file, error }).pipe(
|
|
||||||
Effect.as<Fff.Result<boolean> | undefined>(undefined),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!out) return
|
|
||||||
if (!out.ok) {
|
|
||||||
yield* Effect.logWarning("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ files, tree, search, file, glob, open, warm, release })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
export const fffLayer = Layer.effect(
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
Service,
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
Effect.gen(function* () {
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const result = yield* Effect.try({
|
||||||
|
try: () =>
|
||||||
|
Fff.create({
|
||||||
|
basePath: location.directory,
|
||||||
|
aiMode: true,
|
||||||
|
enableFsRootScanning: true,
|
||||||
|
enableHomeDirScanning: true,
|
||||||
|
}),
|
||||||
|
catch: (cause) => cause,
|
||||||
|
}).pipe(Effect.orDie)
|
||||||
|
if (!result.ok) return yield* Effect.die(result.error)
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
|
||||||
|
const scanned = yield* Effect.tryPromise({
|
||||||
|
try: () => result.value.waitForScan(5_000),
|
||||||
|
catch: (cause) => cause,
|
||||||
|
}).pipe(Effect.orDie)
|
||||||
|
if (!scanned.ok || !scanned.value) return yield* Effect.die(scanned.ok ? "fff scan timed out" : scanned.error)
|
||||||
|
return Service.of({
|
||||||
|
glob: (input) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
|
||||||
|
const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: input.limit,
|
||||||
|
})
|
||||||
|
if (!found.ok) throw found.error
|
||||||
|
return found.value.items.map((item) => {
|
||||||
|
const absolute = path.resolve(location.directory, item.relativePath)
|
||||||
|
return new FileSystem.Entry({
|
||||||
|
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
|
||||||
|
type: "file",
|
||||||
|
mime: FSUtil.mimeType(absolute),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
grep: (input) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
|
||||||
|
const found = result.value.grep(
|
||||||
|
[prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
|
||||||
|
.filter((value) => value !== undefined)
|
||||||
|
.join(" "),
|
||||||
|
{ mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 },
|
||||||
|
)
|
||||||
|
if (!found.ok) throw found.error
|
||||||
|
return found.value.items.map((match) => {
|
||||||
|
const bytes = Buffer.from(match.lineContent)
|
||||||
|
return new FileSystem.Match({
|
||||||
|
entry: new FileSystem.Entry({
|
||||||
|
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
|
||||||
|
type: "file",
|
||||||
|
mime: FSUtil.mimeType(match.relativePath),
|
||||||
|
}),
|
||||||
|
line: match.lineNumber,
|
||||||
|
offset: match.byteOffset,
|
||||||
|
text: match.lineContent.length > 2_000 ? match.lineContent.slice(0, 2_000) + "..." : match.lineContent,
|
||||||
|
submatches: match.matchRanges.map(([start, end]) => ({
|
||||||
|
text: bytes.subarray(start, end).toString("utf8"),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
find: (input) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
|
||||||
|
const items = (() => {
|
||||||
|
if (input.type === "file") {
|
||||||
|
const found = result.value.fileSearch(input.query.trim(), options)
|
||||||
|
if (!found.ok) throw found.error
|
||||||
|
return found.value.items.map((item) => ({ path: item.relativePath, type: "file" as const }))
|
||||||
|
}
|
||||||
|
if (input.type === "directory") {
|
||||||
|
const found = result.value.directorySearch(input.query.trim(), options)
|
||||||
|
if (!found.ok) throw found.error
|
||||||
|
return found.value.items.map((item) => ({ path: item.relativePath, type: "directory" as const }))
|
||||||
|
}
|
||||||
|
const found = result.value.mixedSearch(input.query.trim(), options)
|
||||||
|
if (!found.ok) throw found.error
|
||||||
|
return found.value.items.map((item) => ({ path: item.item.relativePath, type: item.type }))
|
||||||
|
})()
|
||||||
|
return items.map((item) => {
|
||||||
|
const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "")
|
||||||
|
const absolute = path.resolve(location.directory, relative)
|
||||||
|
return new FileSystem.Entry({
|
||||||
|
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
|
||||||
|
type: item.type,
|
||||||
|
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
export const node = LayerNode.make(layer, [FSUtil.node, Ripgrep.node])
|
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
export const defaultLayer = Layer.unwrap(Effect.sync(() => (Fff.available() ? fffLayer : ripgrepLayer)))
|
||||||
|
|
||||||
export function tree(input: Ripgrep.TreeInput) {
|
|
||||||
return runPromise((svc) => svc.tree(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function search(input: Ripgrep.SearchInput) {
|
|
||||||
return runPromise((svc) => svc.search(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function file(input: FileInput) {
|
|
||||||
return runPromise((svc) => svc.file(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function glob(input: GlobInput) {
|
|
||||||
return runPromise((svc) => svc.glob(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function open(input: { cwd?: string; file: string }) {
|
|
||||||
return runPromise((svc) => svc.open(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export * as Search from "./search"
|
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ import { Database } from "./database/database"
|
|||||||
import { PermissionV2 } from "./permission"
|
import { PermissionV2 } from "./permission"
|
||||||
import { PermissionSaved } from "./permission/saved"
|
import { PermissionSaved } from "./permission/saved"
|
||||||
import { FileSystem } from "./filesystem"
|
import { FileSystem } from "./filesystem"
|
||||||
|
import { Ripgrep } from "./ripgrep"
|
||||||
import { Watcher } from "./filesystem/watcher"
|
import { Watcher } from "./filesystem/watcher"
|
||||||
import { Search } from "./filesystem/search"
|
|
||||||
import { LocationMutation } from "./location-mutation"
|
import { LocationMutation } from "./location-mutation"
|
||||||
import { LocationSearch } from "./location-search"
|
|
||||||
import { FileMutation } from "./file-mutation"
|
import { FileMutation } from "./file-mutation"
|
||||||
import { Reference } from "./reference"
|
import { Reference } from "./reference"
|
||||||
import { RepositoryCache } from "./repository-cache"
|
import { RepositoryCache } from "./repository-cache"
|
||||||
@@ -34,7 +33,6 @@ import { ToolRegistry } from "./tool/registry"
|
|||||||
import { ApplicationTools } from "./tool/application-tools"
|
import { ApplicationTools } from "./tool/application-tools"
|
||||||
import { ToolOutputStore } from "./tool-output-store"
|
import { ToolOutputStore } from "./tool-output-store"
|
||||||
import { AppProcess } from "./process"
|
import { AppProcess } from "./process"
|
||||||
import { Ripgrep } from "./ripgrep"
|
|
||||||
import { SessionStore } from "./session/store"
|
import { SessionStore } from "./session/store"
|
||||||
import { SessionTodo } from "./session/todo"
|
import { SessionTodo } from "./session/todo"
|
||||||
import { QuestionV2 } from "./question"
|
import { QuestionV2 } from "./question"
|
||||||
@@ -75,14 +73,12 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||||||
const services = Layer.mergeAll(base, resources, permissionsAndTools)
|
const services = Layer.mergeAll(base, resources, permissionsAndTools)
|
||||||
const image = Image.layer.pipe(Layer.provide(services))
|
const image = Image.layer.pipe(Layer.provide(services))
|
||||||
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
|
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
|
||||||
const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
|
|
||||||
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
|
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
|
||||||
const todos = SessionTodo.layer.pipe(Layer.provide(services))
|
const todos = SessionTodo.layer.pipe(Layer.provide(services))
|
||||||
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
|
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
|
||||||
const builtInTools = BuiltInTools.locationLayer.pipe(
|
const builtInTools = BuiltInTools.locationLayer.pipe(
|
||||||
Layer.provide(services),
|
Layer.provide(services),
|
||||||
Layer.provide(mutation),
|
Layer.provide(mutation),
|
||||||
Layer.provide(searches),
|
|
||||||
Layer.provide(resources),
|
Layer.provide(resources),
|
||||||
Layer.provide(todos),
|
Layer.provide(todos),
|
||||||
Layer.provide(questions),
|
Layer.provide(questions),
|
||||||
@@ -94,18 +90,9 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||||||
Layer.provide(model),
|
Layer.provide(model),
|
||||||
Layer.provide(skillGuidance),
|
Layer.provide(skillGuidance),
|
||||||
)
|
)
|
||||||
return Layer.mergeAll(
|
return Layer.mergeAll(services, image, mutation, resources, todos, questions, model, runner, builtInTools).pipe(
|
||||||
services,
|
Layer.fresh,
|
||||||
image,
|
)
|
||||||
mutation,
|
|
||||||
searches,
|
|
||||||
resources,
|
|
||||||
todos,
|
|
||||||
questions,
|
|
||||||
model,
|
|
||||||
runner,
|
|
||||||
builtInTools,
|
|
||||||
).pipe(Layer.fresh)
|
|
||||||
},
|
},
|
||||||
idleTimeToLive: "60 minutes",
|
idleTimeToLive: "60 minutes",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -117,6 +104,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||||||
FSUtil.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
AppProcess.defaultLayer,
|
AppProcess.defaultLayer,
|
||||||
Global.defaultLayer,
|
Global.defaultLayer,
|
||||||
|
Ripgrep.defaultLayer,
|
||||||
Database.defaultLayer,
|
Database.defaultLayer,
|
||||||
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
|
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
|
||||||
PermissionSaved.defaultLayer,
|
PermissionSaved.defaultLayer,
|
||||||
@@ -125,6 +113,5 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||||||
FetchHttpClient.layer,
|
FetchHttpClient.layer,
|
||||||
ToolOutputStore.defaultCleanupLayer,
|
ToolOutputStore.defaultCleanupLayer,
|
||||||
ApplicationTools.layer,
|
ApplicationTools.layer,
|
||||||
Search.defaultLayer,
|
|
||||||
],
|
],
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
export * as LocationSearch from "./location-search"
|
|
||||||
|
|
||||||
import path from "path"
|
|
||||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
|
||||||
import { FSUtil } from "./fs-util"
|
|
||||||
import { Global } from "./global"
|
|
||||||
import { Location } from "./location"
|
|
||||||
import { Ripgrep } from "./ripgrep"
|
|
||||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
|
||||||
import { ToolOutputStore } from "./tool-output-store"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Location-scoped raw search substrate. Search authority is selected only by
|
|
||||||
* FileSystem, preserving Location-relative paths. Model formatting, leaf-tool permissions, and HTTP transport stay
|
|
||||||
* outside this service so future GlobTool, GrepTool, and HTTP consumers can
|
|
||||||
* share the same bounded filesystem behavior.
|
|
||||||
*
|
|
||||||
* TODO: Expose this substrate through HTTP fs.search/fs.grep endpoints.
|
|
||||||
* TODO: Reuse this substrate for instruction and skill discovery where suitable.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const DEFAULT_RESULT_LIMIT = 100
|
|
||||||
export const MAX_RESULT_LIMIT = 100
|
|
||||||
export const MAX_LINE_PREVIEW_LENGTH = 2_000
|
|
||||||
|
|
||||||
export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESULT_LIMIT))
|
|
||||||
|
|
||||||
export const FilesInput = Schema.Struct({
|
|
||||||
pattern: Schema.String,
|
|
||||||
path: Schema.String.pipe(Schema.optional),
|
|
||||||
limit: ResultLimit.pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSignal }
|
|
||||||
|
|
||||||
export const GrepInput = Schema.Struct({
|
|
||||||
pattern: Schema.String,
|
|
||||||
include: Schema.String.pipe(Schema.optional),
|
|
||||||
path: Schema.String.pipe(Schema.optional),
|
|
||||||
limit: ResultLimit.pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
export type GrepInput = typeof GrepInput.Type & { readonly signal?: AbortSignal }
|
|
||||||
|
|
||||||
export class File extends Schema.Class<File>("LocationSearch.File")({
|
|
||||||
path: RelativePath,
|
|
||||||
canonical: Schema.String,
|
|
||||||
resource: Schema.String,
|
|
||||||
mtime: Schema.Number,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class Submatch extends Schema.Class<Submatch>("LocationSearch.Submatch")({
|
|
||||||
text: Schema.String,
|
|
||||||
start: NonNegativeInt,
|
|
||||||
end: NonNegativeInt,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class Match extends Schema.Class<Match>("LocationSearch.Match")({
|
|
||||||
path: RelativePath,
|
|
||||||
canonical: Schema.String,
|
|
||||||
resource: Schema.String,
|
|
||||||
lines: Schema.String,
|
|
||||||
linePreviewTruncated: Schema.Boolean,
|
|
||||||
line: PositiveInt,
|
|
||||||
offset: NonNegativeInt,
|
|
||||||
submatches: Schema.Array(Submatch),
|
|
||||||
mtime: Schema.Number,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class FilesResult extends Schema.Class<FilesResult>("LocationSearch.FilesResult")({
|
|
||||||
items: Schema.Array(File),
|
|
||||||
truncated: Schema.Boolean,
|
|
||||||
partial: Schema.Boolean,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class GrepResult extends Schema.Class<GrepResult>("LocationSearch.GrepResult")({
|
|
||||||
items: Schema.Array(Match),
|
|
||||||
truncated: Schema.Boolean,
|
|
||||||
partial: Schema.Boolean,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly files: (input: FilesInput) => Effect.Effect<FilesResult, Ripgrep.Error>
|
|
||||||
readonly grep: (input: GrepInput) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationSearch") {}
|
|
||||||
|
|
||||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
|
||||||
const cap = (limit?: number) => Math.min(limit ?? DEFAULT_RESULT_LIMIT, MAX_RESULT_LIMIT)
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fs = yield* FSUtil.Service
|
|
||||||
const location = yield* Location.Service
|
|
||||||
const global = yield* Effect.serviceOption(Global.Service)
|
|
||||||
const ripgrep = yield* Ripgrep.Service
|
|
||||||
|
|
||||||
const resolve = Effect.fnUntraced(function* (input?: string) {
|
|
||||||
const directory = input && path.isAbsolute(input) ? path.dirname(input) : location.directory
|
|
||||||
const absolute = path.resolve(location.directory, input ?? ".")
|
|
||||||
if (!path.isAbsolute(input ?? "") && !FSUtil.contains(location.directory, absolute))
|
|
||||||
return yield* Effect.die(new globalThis.Error("Path escapes the location"))
|
|
||||||
if (path.isAbsolute(input ?? "")) {
|
|
||||||
const managed = path.join(
|
|
||||||
Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
|
|
||||||
ToolOutputStore.MANAGED_DIRECTORY,
|
|
||||||
)
|
|
||||||
if (directory !== managed || !path.basename(absolute).startsWith("tool_"))
|
|
||||||
return yield* Effect.die(new globalThis.Error("Absolute path is not managed tool output"))
|
|
||||||
}
|
|
||||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
|
||||||
const root = yield* fs.realPath(directory).pipe(Effect.orDie)
|
|
||||||
if (!FSUtil.contains(root, real)) return yield* Effect.die(new globalThis.Error("Path escapes the search root"))
|
|
||||||
const info = yield* fs.stat(real).pipe(Effect.orDie)
|
|
||||||
const type =
|
|
||||||
info.type === "File" ? ("file" as const) : info.type === "Directory" ? ("directory" as const) : undefined
|
|
||||||
if (!type) return yield* Effect.die(new globalThis.Error("Search root is not a file or directory"))
|
|
||||||
return { real, root, resource: slash(path.relative(root, real)) || ".", type }
|
|
||||||
})
|
|
||||||
|
|
||||||
const candidate = Effect.fnUntraced(function* (
|
|
||||||
root: { readonly real: string; readonly root: string; readonly type: "file" | "directory" },
|
|
||||||
cwd: string,
|
|
||||||
value: string,
|
|
||||||
) {
|
|
||||||
const absolute = path.resolve(cwd, value)
|
|
||||||
const lexicallyContained =
|
|
||||||
root.type === "directory" ? FSUtil.contains(root.real, absolute) : absolute === root.real
|
|
||||||
if (!lexicallyContained) return
|
|
||||||
const canonical = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
|
|
||||||
if (!canonical || !FSUtil.contains(root.root, canonical)) return
|
|
||||||
const info = yield* fs.stat(canonical).pipe(Effect.catch(() => Effect.void))
|
|
||||||
if (!info || info.type !== "File") return
|
|
||||||
const relative = slash(path.relative(root.root, canonical))
|
|
||||||
return {
|
|
||||||
path: RelativePath.make(relative),
|
|
||||||
canonical,
|
|
||||||
resource: relative,
|
|
||||||
mtime: info.mtime.pipe(
|
|
||||||
Option.map((date) => date.getTime()),
|
|
||||||
Option.getOrElse(() => 0),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
files: Effect.fn("LocationSearch.files")(function* (input) {
|
|
||||||
const root = yield* resolve(input.path)
|
|
||||||
if (root.type !== "directory")
|
|
||||||
return yield* Effect.die(new globalThis.Error("Files search path must be a directory"))
|
|
||||||
const result = yield* ripgrep.files({
|
|
||||||
cwd: root.real,
|
|
||||||
pattern: input.pattern,
|
|
||||||
limit: cap(input.limit),
|
|
||||||
signal: input.signal,
|
|
||||||
})
|
|
||||||
const mapped = yield* Effect.forEach(result.items, (item) => candidate(root, root.real, item), {
|
|
||||||
concurrency: 16,
|
|
||||||
})
|
|
||||||
const items = mapped.filter((item): item is File => item !== undefined).map((item) => new File(item))
|
|
||||||
// TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering.
|
|
||||||
// TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical.
|
|
||||||
return new FilesResult({
|
|
||||||
items,
|
|
||||||
truncated: result.truncated,
|
|
||||||
partial: result.partial || items.length !== result.items.length,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
grep: Effect.fn("LocationSearch.grep")(function* (input) {
|
|
||||||
const root = yield* resolve(input.path)
|
|
||||||
const cwd = root.type === "directory" ? root.real : path.dirname(root.real)
|
|
||||||
const result = yield* ripgrep.grep({
|
|
||||||
cwd,
|
|
||||||
pattern: input.pattern,
|
|
||||||
include: input.include,
|
|
||||||
file: root.type === "file" ? path.basename(root.real) : undefined,
|
|
||||||
limit: cap(input.limit),
|
|
||||||
signal: input.signal,
|
|
||||||
})
|
|
||||||
const candidates = new Map<string, ReturnType<typeof candidate>>()
|
|
||||||
for (const item of result.items) {
|
|
||||||
if (!candidates.has(item.path.text)) {
|
|
||||||
candidates.set(item.path.text, yield* Effect.cached(candidate(root, cwd, item.path.text)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mapped = yield* Effect.forEach(
|
|
||||||
result.items,
|
|
||||||
(item) =>
|
|
||||||
candidates.get(item.path.text)!.pipe(
|
|
||||||
Effect.map(
|
|
||||||
(file) =>
|
|
||||||
file &&
|
|
||||||
new Match({
|
|
||||||
...file,
|
|
||||||
lines: item.lines.text.slice(0, MAX_LINE_PREVIEW_LENGTH),
|
|
||||||
linePreviewTruncated: item.lines.text.length > MAX_LINE_PREVIEW_LENGTH,
|
|
||||||
line: item.line_number,
|
|
||||||
offset: item.absolute_offset,
|
|
||||||
submatches: item.submatches.map(
|
|
||||||
(submatch) =>
|
|
||||||
new Submatch({ text: submatch.match.text, start: submatch.start, end: submatch.end }),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
{ concurrency: 16 },
|
|
||||||
)
|
|
||||||
const items = mapped.filter((item): item is Match => item !== undefined)
|
|
||||||
// TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering.
|
|
||||||
// TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical.
|
|
||||||
return new GrepResult({
|
|
||||||
items,
|
|
||||||
truncated: result.truncated,
|
|
||||||
partial: result.partial || items.length !== result.items.length,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
@@ -32,7 +32,8 @@ class SessionModelValidation extends Context.Service<
|
|||||||
}
|
}
|
||||||
>()("@opencode/public/OpenCode/SessionModelValidation") {}
|
>()("@opencode/public/OpenCode/SessionModelValidation") {}
|
||||||
|
|
||||||
const LocationServicesLayer = LocationServiceMap.layer
|
const ApplicationToolsLayer = ApplicationTools.layer
|
||||||
|
const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer))
|
||||||
const SessionModelValidationLayer = Layer.effect(
|
const SessionModelValidationLayer = Layer.effect(
|
||||||
SessionModelValidation,
|
SessionModelValidation,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -78,8 +79,6 @@ const SessionsLayer = Layer.merge(
|
|||||||
),
|
),
|
||||||
SessionModelValidationLayer,
|
SessionModelValidationLayer,
|
||||||
).pipe(Layer.provide(LocationServicesLayer))
|
).pipe(Layer.provide(LocationServicesLayer))
|
||||||
const ApplicationToolsLayer = ApplicationTools.layer
|
|
||||||
|
|
||||||
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
|
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
|
|||||||
+122
-30
@@ -2,20 +2,23 @@ export * as Ripgrep from "./ripgrep"
|
|||||||
|
|
||||||
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { Ripgrep as FileSystemRipgrep } from "./filesystem/ripgrep"
|
import path from "path"
|
||||||
|
import { Entry, Match } from "./filesystem/schema"
|
||||||
|
import { FSUtil } from "./fs-util"
|
||||||
import { AppProcess, collectStream, waitForAbort } from "./process"
|
import { AppProcess, collectStream, waitForAbort } from "./process"
|
||||||
import { NonNegativeInt, PositiveInt } from "./schema"
|
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||||
|
import { RipgrepBinary } from "./ripgrep/binary"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small core-owned ripgrep execution adapter. It deliberately exposes raw
|
* Small core-owned ripgrep execution adapter. It deliberately exposes raw
|
||||||
* process-oriented rows, not model text or permission behavior. LocationSearch
|
* process-oriented rows, not model text or permission behavior. Search maps
|
||||||
* supplies read authority and bounded substrate results; future leaf tools own
|
* these rows into filesystem results; leaf tools own
|
||||||
* presentation and permission prompts.
|
* presentation and permission prompts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ERROR_BYTES = 8 * 1024
|
const ERROR_BYTES = 8 * 1024
|
||||||
export const MAX_RECORD_BYTES = 64 * 1024
|
const MAX_RECORD_BYTES = 64 * 1024
|
||||||
export const MAX_SUBMATCHES = 100
|
const MAX_SUBMATCHES = 100
|
||||||
|
|
||||||
const RawMatch = Schema.Struct({
|
const RawMatch = Schema.Struct({
|
||||||
type: Schema.Literal("match"),
|
type: Schema.Literal("match"),
|
||||||
@@ -34,7 +37,7 @@ const RawMatch = Schema.Struct({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type Match = (typeof RawMatch.Type)["data"]
|
type RawMatchData = (typeof RawMatch.Type)["data"]
|
||||||
|
|
||||||
export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
|
export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
@@ -46,16 +49,21 @@ export class InvalidPatternError extends Schema.TaggedErrorClass<InvalidPatternE
|
|||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export interface Result<A> {
|
export interface FindInput {
|
||||||
readonly items: A[]
|
|
||||||
readonly truncated: boolean
|
|
||||||
readonly partial: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FilesInput {
|
|
||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
readonly pattern: string
|
readonly pattern: string
|
||||||
readonly limit: number
|
readonly limit: number
|
||||||
|
readonly hidden?: boolean
|
||||||
|
readonly follow?: boolean
|
||||||
|
readonly signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobInput {
|
||||||
|
readonly cwd: string
|
||||||
|
readonly pattern: string
|
||||||
|
readonly limit: number
|
||||||
|
readonly hidden?: boolean
|
||||||
|
readonly follow?: boolean
|
||||||
readonly signal?: AbortSignal
|
readonly signal?: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,8 +77,9 @@ export interface GrepInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly files: (input: FilesInput) => Effect.Effect<Result<string>, Error>
|
readonly find: (input: FindInput) => Effect.Effect<readonly Entry[], Error>
|
||||||
readonly grep: (input: GrepInput) => Effect.Effect<Result<Match>, Error | InvalidPatternError>
|
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[], Error>
|
||||||
|
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[], Error | InvalidPatternError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Ripgrep") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Ripgrep") {}
|
||||||
@@ -84,7 +93,7 @@ export const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const process = yield* AppProcess.Service
|
const process = yield* AppProcess.Service
|
||||||
const binary = yield* FileSystemRipgrep.Service
|
const binary = yield* RipgrepBinary.Service
|
||||||
|
|
||||||
const run = <A>(input: {
|
const run = <A>(input: {
|
||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
@@ -137,31 +146,84 @@ export const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
files: (input) =>
|
glob: (input) =>
|
||||||
run<string>({
|
run<string>({
|
||||||
...input,
|
cwd: input.cwd,
|
||||||
|
limit: input.limit,
|
||||||
|
signal: input.signal,
|
||||||
args: [
|
args: [
|
||||||
"--no-config",
|
"--no-config",
|
||||||
"--files",
|
"--files",
|
||||||
"--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure.
|
"--glob=!**/.git/**",
|
||||||
|
...(input.hidden ? ["--hidden"] : []),
|
||||||
|
...(input.follow ? ["--follow"] : []),
|
||||||
`--glob=${input.pattern}`,
|
`--glob=${input.pattern}`,
|
||||||
"--glob=!.*",
|
|
||||||
"--glob=!**/.*",
|
|
||||||
".",
|
".",
|
||||||
],
|
],
|
||||||
parse: (line) => Effect.succeed(line.replace(/^\.\//, "")),
|
parse: (line) =>
|
||||||
}).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
|
Effect.succeed(
|
||||||
|
line
|
||||||
|
.replace(/^(?:\.[\\/])+/u, "")
|
||||||
|
.replace(/^[\\/]+/u, "")
|
||||||
|
.replaceAll("\\", "/"),
|
||||||
|
),
|
||||||
|
}).pipe(
|
||||||
|
Effect.map((result) =>
|
||||||
|
result.items.map((relative) => {
|
||||||
|
const absolute = path.resolve(input.cwd, relative)
|
||||||
|
return new Entry({
|
||||||
|
path: RelativePath.make(relative),
|
||||||
|
type: "file",
|
||||||
|
mime: FSUtil.mimeType(absolute),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
|
||||||
|
),
|
||||||
|
find: (input) =>
|
||||||
|
run<string>({
|
||||||
|
cwd: input.cwd,
|
||||||
|
limit: input.limit,
|
||||||
|
signal: input.signal,
|
||||||
|
args: [
|
||||||
|
"--no-config",
|
||||||
|
"--files",
|
||||||
|
"--glob=!**/.git/**",
|
||||||
|
...(input.hidden ? ["--hidden"] : []),
|
||||||
|
...(input.follow ? ["--follow"] : []),
|
||||||
|
`--glob=${input.pattern}`,
|
||||||
|
".",
|
||||||
|
],
|
||||||
|
parse: (line) =>
|
||||||
|
Effect.succeed(
|
||||||
|
line
|
||||||
|
.replace(/^(?:\.[\\/])+/u, "")
|
||||||
|
.replace(/^[\\/]+/u, "")
|
||||||
|
.replaceAll("\\", "/"),
|
||||||
|
),
|
||||||
|
}).pipe(
|
||||||
|
Effect.map((result) =>
|
||||||
|
result.items.map((relative) => {
|
||||||
|
const absolute = path.resolve(input.cwd, relative)
|
||||||
|
return new Entry({
|
||||||
|
path: RelativePath.make(relative),
|
||||||
|
type: "file",
|
||||||
|
mime: FSUtil.mimeType(absolute),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
|
||||||
|
),
|
||||||
grep: (input) =>
|
grep: (input) =>
|
||||||
run<Match>({
|
run<RawMatchData>({
|
||||||
...input,
|
...input,
|
||||||
args: [
|
args: [
|
||||||
"--no-config",
|
"--no-config",
|
||||||
"--json",
|
"--json",
|
||||||
"--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure.
|
"--hidden",
|
||||||
|
"--glob=!**/.git/**",
|
||||||
"--no-messages",
|
"--no-messages",
|
||||||
...(input.include ? [`--glob=${input.include}`] : []),
|
...(input.include ? [`--glob=${input.include}`] : []),
|
||||||
"--glob=!.*",
|
|
||||||
"--glob=!**/.*",
|
|
||||||
"--",
|
"--",
|
||||||
input.pattern,
|
input.pattern,
|
||||||
input.file ?? ".",
|
input.file ?? ".",
|
||||||
@@ -180,13 +242,43 @@ export const layer = Layer.effect(
|
|||||||
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
|
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
|
||||||
Effect.map((match) => ({
|
Effect.map((match) => ({
|
||||||
...match.data,
|
...match.data,
|
||||||
|
path: { text: match.data.path.text.replace(/^\.[\\/]/, "") },
|
||||||
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
|
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
|
||||||
})),
|
})),
|
||||||
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
|
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}).pipe(
|
||||||
|
Effect.map((result) =>
|
||||||
|
result.items.map((match) => {
|
||||||
|
const relative = match.path.text
|
||||||
|
.replace(/^(?:\.[\\/])+/u, "")
|
||||||
|
.replace(/^[\\/]+/u, "")
|
||||||
|
.replaceAll("\\", "/")
|
||||||
|
const absolute = path.resolve(input.cwd, relative)
|
||||||
|
return new Match({
|
||||||
|
entry: new Entry({
|
||||||
|
path: RelativePath.make(relative),
|
||||||
|
type: "file",
|
||||||
|
mime: FSUtil.mimeType(absolute),
|
||||||
|
}),
|
||||||
|
line: match.line_number,
|
||||||
|
offset: match.absolute_offset,
|
||||||
|
text: match.lines.text.length > 2_000 ? match.lines.text.slice(0, 2_000) + "..." : match.lines.text,
|
||||||
|
submatches: match.submatches.map((submatch) => ({
|
||||||
|
text: submatch.match.text,
|
||||||
|
start: submatch.start,
|
||||||
|
end: submatch.end,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
).pipe(Layer.provide(FileSystemRipgrep.defaultLayer))
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { Context, Effect, Layer, Stream } from "effect"
|
||||||
|
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
|
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
|
||||||
|
import { FSUtil } from "../fs-util"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { which } from "../util/which"
|
||||||
|
|
||||||
|
export namespace RipgrepBinary {
|
||||||
|
const VERSION = "15.1.0"
|
||||||
|
const PLATFORM = {
|
||||||
|
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
|
||||||
|
"arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
|
||||||
|
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
|
||||||
|
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
|
||||||
|
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
|
||||||
|
"ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" },
|
||||||
|
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
interface Interface {
|
||||||
|
readonly filepath: Effect.Effect<string, Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/RipgrepBinary") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||||
|
const spawner = yield* ChildProcessSpawner
|
||||||
|
|
||||||
|
const run = Effect.fnUntraced(function* (command: string, args: string[]) {
|
||||||
|
const handle = yield* spawner.spawn(ChildProcess.make(command, args, { extendEnv: true, stdin: "ignore" }))
|
||||||
|
const [stdout, stderr, code] = yield* Effect.all(
|
||||||
|
[
|
||||||
|
Stream.mkString(Stream.decodeText(handle.stdout)),
|
||||||
|
Stream.mkString(Stream.decodeText(handle.stderr)),
|
||||||
|
handle.exitCode,
|
||||||
|
],
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
return { stdout, stderr, code }
|
||||||
|
}, Effect.scoped)
|
||||||
|
|
||||||
|
const extract = Effect.fnUntraced(function* (
|
||||||
|
archive: string,
|
||||||
|
config: (typeof PLATFORM)[keyof typeof PLATFORM],
|
||||||
|
target: string,
|
||||||
|
) {
|
||||||
|
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
|
||||||
|
|
||||||
|
if (config.extension === "zip") {
|
||||||
|
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
|
||||||
|
const result = yield* run(shell, [
|
||||||
|
"-NoProfile",
|
||||||
|
"-NonInteractive",
|
||||||
|
"-Command",
|
||||||
|
`$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`,
|
||||||
|
])
|
||||||
|
if (result.code !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || `ripgrep extraction failed with code ${result.code}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.extension === "tar.gz") {
|
||||||
|
const result = yield* run("tar", ["-xzf", archive, "-C", dir])
|
||||||
|
if (result.code !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || `ripgrep extraction failed with code ${result.code}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const extracted = path.join(
|
||||||
|
dir,
|
||||||
|
`ripgrep-${VERSION}-${config.platform}`,
|
||||||
|
process.platform === "win32" ? "rg.exe" : "rg",
|
||||||
|
)
|
||||||
|
if (!(yield* fs.isFile(extracted))) throw new Error(`ripgrep archive did not contain executable: ${extracted}`)
|
||||||
|
|
||||||
|
yield* fs.copyFile(extracted, target)
|
||||||
|
if (process.platform !== "win32") yield* fs.chmod(target, 0o755)
|
||||||
|
}, Effect.scoped)
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
filepath: yield* Effect.cached(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
|
||||||
|
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
|
||||||
|
|
||||||
|
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
|
||||||
|
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
|
||||||
|
|
||||||
|
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
||||||
|
const config = PLATFORM[platformKey]
|
||||||
|
if (!config) throw new Error(`unsupported platform for ripgrep: ${platformKey}`)
|
||||||
|
|
||||||
|
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
|
||||||
|
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
|
||||||
|
const archive = path.join(Global.Path.bin, filename)
|
||||||
|
|
||||||
|
yield* Effect.logInfo("downloading ripgrep", { url })
|
||||||
|
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
|
||||||
|
const bytes = yield* HttpClientRequest.get(url).pipe(
|
||||||
|
http.execute,
|
||||||
|
Effect.flatMap((response) => response.arrayBuffer),
|
||||||
|
Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
|
||||||
|
)
|
||||||
|
if (bytes.byteLength === 0) throw new Error(`failed to download ripgrep from ${url}`)
|
||||||
|
|
||||||
|
yield* fs.writeWithDirs(archive, new Uint8Array(bytes))
|
||||||
|
yield* extract(archive, config, target)
|
||||||
|
yield* fs.remove(archive, { force: true }).pipe(Effect.ignore)
|
||||||
|
return target
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(FetchHttpClient.layer),
|
||||||
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,11 @@ export * as GlobTool from "./glob"
|
|||||||
|
|
||||||
import { ToolFailure } from "@opencode-ai/llm"
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { LocationSearch } from "../location-search"
|
import path from "path"
|
||||||
|
import { FileSystem } from "../filesystem"
|
||||||
|
import { Location } from "../location"
|
||||||
|
import { Ripgrep } from "../ripgrep"
|
||||||
|
import { RelativePath } from "../schema"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { Tools } from "./tools"
|
import { Tools } from "./tools"
|
||||||
@@ -10,38 +14,30 @@ import { Tools } from "./tools"
|
|||||||
export const name = "glob"
|
export const name = "glob"
|
||||||
|
|
||||||
export const Input = Schema.Struct({
|
export const Input = Schema.Struct({
|
||||||
pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
|
pattern: FileSystem.GlobInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
|
||||||
path: LocationSearch.FilesInput.fields.path.annotate({
|
path: RelativePath.pipe(Schema.optional).annotate({
|
||||||
description: "Relative directory to search. Defaults to the active Location.",
|
description: "Relative directory to search. Defaults to the active Location.",
|
||||||
}),
|
}),
|
||||||
limit: LocationSearch.FilesInput.fields.limit.annotate({
|
limit: FileSystem.GlobInput.fields.limit.annotate({
|
||||||
description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
|
description: "Maximum results to return",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
type ModelOutput = typeof LocationSearch.FilesResult.Encoded
|
export const Output = Schema.Array(FileSystem.Entry)
|
||||||
|
type ModelOutput = typeof Output.Encoded
|
||||||
|
|
||||||
/** Format raw Location search results into the concise line-oriented output models expect. */
|
/** Format raw search results into the concise line-oriented output models expect. */
|
||||||
export const toModelOutput = (output: ModelOutput) => {
|
export const toModelOutput = (output: ModelOutput) => {
|
||||||
const lines = output.items.length === 0 ? ["No files found"] : output.items.map((item) => item.resource)
|
const lines = output.length === 0 ? ["No files found"] : output.map((item) => item.path)
|
||||||
if (output.truncated) {
|
|
||||||
lines.push(
|
|
||||||
"",
|
|
||||||
`(Results are truncated: showing first ${output.items.length} results. Consider using a more specific path or pattern.)`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (output.partial) lines.push("", "(Results may be incomplete because some discovered files could not be read.)")
|
|
||||||
return lines.join("\n")
|
return lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Glob leaf that defaults its filesystem root to the active Location. */
|
||||||
* Location-scoped glob leaf. FileSystem supplies canonical permission metadata;
|
|
||||||
* LocationSearch resolves the current root and owns containment and traversal.
|
|
||||||
*/
|
|
||||||
export const layer = Layer.effectDiscard(
|
export const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const tools = yield* Tools.Service
|
const tools = yield* Tools.Service
|
||||||
const search = yield* LocationSearch.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
const permission = yield* PermissionV2.Service
|
const permission = yield* PermissionV2.Service
|
||||||
|
|
||||||
yield* tools
|
yield* tools
|
||||||
@@ -50,8 +46,13 @@ export const layer = Layer.effectDiscard(
|
|||||||
description:
|
description:
|
||||||
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||||
input: Input,
|
input: Input,
|
||||||
output: LocationSearch.FilesResult,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
toModelOutput: ({ output }) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: toModelOutput(output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) }))),
|
||||||
|
},
|
||||||
|
],
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
@@ -67,7 +68,22 @@ export const layer = Layer.effectDiscard(
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||||
})
|
})
|
||||||
return yield* search.files(input)
|
const cwd = path.resolve(location.directory, input.path ?? ".")
|
||||||
|
return yield* ripgrep.glob({
|
||||||
|
cwd,
|
||||||
|
pattern: input.pattern,
|
||||||
|
limit: input.limit ?? Number.MAX_SAFE_INTEGER,
|
||||||
|
}).pipe(
|
||||||
|
Effect.map((result) =>
|
||||||
|
result.map(
|
||||||
|
(entry) =>
|
||||||
|
new FileSystem.Entry({
|
||||||
|
...entry,
|
||||||
|
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
|
Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,61 +2,58 @@ export * as GrepTool from "./grep"
|
|||||||
|
|
||||||
import { ToolFailure } from "@opencode-ai/llm"
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { LocationSearch } from "../location-search"
|
import path from "path"
|
||||||
import { Ripgrep } from "../ripgrep"
|
import { FileSystem } from "../filesystem"
|
||||||
|
import { FSUtil } from "../fs-util"
|
||||||
|
import { Location } from "../location"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
|
import { Ripgrep } from "../ripgrep"
|
||||||
|
import { RelativePath } from "../schema"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { Tools } from "./tools"
|
import { Tools } from "./tools"
|
||||||
|
|
||||||
export const name = "grep"
|
export const name = "grep"
|
||||||
|
|
||||||
export const Input = Schema.Struct({
|
export const Input = Schema.Struct({
|
||||||
pattern: LocationSearch.GrepInput.fields.pattern.annotate({
|
pattern: FileSystem.GrepInput.fields.pattern.annotate({
|
||||||
description: "Regex pattern to search for in file contents",
|
description: "Regex pattern to search for in file contents",
|
||||||
}),
|
}),
|
||||||
path: LocationSearch.GrepInput.fields.path.annotate({
|
path: RelativePath.pipe(Schema.optional).annotate({
|
||||||
description: "Relative file or directory to search. Defaults to the active Location.",
|
description: "Relative directory to search. Defaults to the active Location.",
|
||||||
}),
|
}),
|
||||||
include: LocationSearch.GrepInput.fields.include.annotate({
|
include: FileSystem.GrepInput.fields.include.annotate({
|
||||||
description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")',
|
description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")',
|
||||||
}),
|
}),
|
||||||
limit: LocationSearch.GrepInput.fields.limit.annotate({
|
limit: FileSystem.GrepInput.fields.limit.annotate({
|
||||||
description: `Maximum matches to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
|
description: "Maximum matches to return",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
type Output = typeof LocationSearch.GrepResult.Encoded
|
export const Output = Schema.Array(FileSystem.Match)
|
||||||
|
type ModelOutput = typeof Output.Encoded
|
||||||
|
|
||||||
/** Format raw Location search matches into the familiar concise model output. */
|
/** Format raw search matches into the familiar concise model output. */
|
||||||
export const toModelOutput = (output: Output) => {
|
export const toModelOutput = (output: ModelOutput) => {
|
||||||
const lines = output.items.length === 0 ? ["No files found"] : [`Found ${output.items.length} matches`]
|
const lines = output.length === 0 ? ["No files found"] : [`Found ${output.length} matches`]
|
||||||
let current = ""
|
let current = ""
|
||||||
for (const match of output.items) {
|
for (const match of output) {
|
||||||
if (current !== match.resource) {
|
if (current !== match.entry.path) {
|
||||||
if (current) lines.push("")
|
if (current) lines.push("")
|
||||||
current = match.resource
|
current = match.entry.path
|
||||||
lines.push(`${match.resource}:`)
|
lines.push(`${match.entry.path}:`)
|
||||||
}
|
}
|
||||||
lines.push(` Line ${match.line}: ${match.lines}${match.linePreviewTruncated ? "..." : ""}`)
|
lines.push(` Line ${match.line}: ${match.text}`)
|
||||||
}
|
}
|
||||||
if (output.truncated) {
|
|
||||||
lines.push(
|
|
||||||
"",
|
|
||||||
`(Results are truncated: showing first ${output.items.length} matches. Consider using a more specific path or pattern.)`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (output.partial) lines.push("", "(Some paths were inaccessible and skipped)")
|
|
||||||
return lines.join("\n")
|
return lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Grep leaf that defaults its filesystem root to the active Location. */
|
||||||
* Location-scoped grep leaf. FileSystem supplies canonical permission metadata;
|
|
||||||
* LocationSearch resolves the current root and owns containment and ripgrep execution.
|
|
||||||
*/
|
|
||||||
export const layer = Layer.effectDiscard(
|
export const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const tools = yield* Tools.Service
|
const tools = yield* Tools.Service
|
||||||
const search = yield* LocationSearch.Service
|
const fs = yield* FSUtil.Service
|
||||||
|
const ripgrep = yield* Ripgrep.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
const permission = yield* PermissionV2.Service
|
const permission = yield* PermissionV2.Service
|
||||||
|
|
||||||
yield* tools
|
yield* tools
|
||||||
@@ -65,8 +62,18 @@ export const layer = Layer.effectDiscard(
|
|||||||
description:
|
description:
|
||||||
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
|
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
|
||||||
input: Input,
|
input: Input,
|
||||||
output: LocationSearch.GrepResult,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
toModelOutput: ({ output }) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: toModelOutput(
|
||||||
|
output.map((match) => ({
|
||||||
|
...match,
|
||||||
|
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
@@ -74,7 +81,7 @@ export const layer = Layer.effectDiscard(
|
|||||||
resources: [input.pattern],
|
resources: [input.pattern],
|
||||||
save: ["*"],
|
save: ["*"],
|
||||||
metadata: {
|
metadata: {
|
||||||
root: input.path ?? ".",
|
root: ".",
|
||||||
path: input.path,
|
path: input.path,
|
||||||
include: input.include,
|
include: input.include,
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
@@ -83,15 +90,35 @@ export const layer = Layer.effectDiscard(
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||||
})
|
})
|
||||||
return yield* search.grep(input)
|
const target = path.resolve(location.directory, input.path ?? ".")
|
||||||
|
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
return yield* ripgrep.grep({
|
||||||
|
cwd: info?.type === "Directory" ? target : path.dirname(target),
|
||||||
|
pattern: input.pattern,
|
||||||
|
file: info?.type === "File" ? path.basename(target) : undefined,
|
||||||
|
include: input.include,
|
||||||
|
limit: input.limit ?? Number.MAX_SAFE_INTEGER,
|
||||||
|
}).pipe(
|
||||||
|
Effect.map((result) =>
|
||||||
|
result.map(
|
||||||
|
(match) =>
|
||||||
|
new FileSystem.Match({
|
||||||
|
...match,
|
||||||
|
entry: new FileSystem.Entry({
|
||||||
|
...match.entry,
|
||||||
|
path: RelativePath.make(
|
||||||
|
path.relative(
|
||||||
|
location.directory,
|
||||||
|
path.resolve(info?.type === "Directory" ? target : path.dirname(target), match.entry.path),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError((error) => {
|
Effect.mapError(() => new ToolFailure({ message: `Unable to grep for ${input.pattern}` })),
|
||||||
const message =
|
|
||||||
error instanceof Ripgrep.InvalidPatternError
|
|
||||||
? `Invalid grep pattern ${JSON.stringify(input.pattern)}: ${error.message}`
|
|
||||||
: `Unable to grep for ${input.pattern}`
|
|
||||||
return new ToolFailure({ message })
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -276,8 +276,7 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
|
|||||||
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
|
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
|
||||||
if (!type) return
|
if (!type) return
|
||||||
return new FileSystem.Entry({
|
return new FileSystem.Entry({
|
||||||
path: RelativePath.make(item.name),
|
path: RelativePath.make(item.name + (type === "directory" ? path.sep : "")),
|
||||||
uri: pathToFileURL(target).href,
|
|
||||||
type,
|
type,
|
||||||
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(target),
|
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(target),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { describe, expect } from "bun:test"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import os from "os"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Fff } from "#fff"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
|
import { tmpdir } from "../fixture/tmpdir"
|
||||||
import { testEffect } from "../lib/effect"
|
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.acquireRelease(
|
||||||
Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))),
|
Effect.promise(() => tmpdir()),
|
||||||
(dir) =>
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
Effect.promise(() => fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })).pipe(
|
).pipe(Effect.flatMap((tmp) => f(AbsolutePath.make(tmp.path))))
|
||||||
Effect.ignore,
|
|
||||||
),
|
|
||||||
).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
|
|
||||||
|
|
||||||
const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
|
describe("Ripgrep", () => {
|
||||||
const waitForFileIndex = (search: Search.Interface, cwd: string) =>
|
it.live("globs files as an array", () =>
|
||||||
search.glob({ cwd, pattern: "**/*", limit: 1 }).pipe(Effect.ignore)
|
withTmp((cwd) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
describe("file.search", () => {
|
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
|
||||||
it.live("uses fff for Bun-backed grep", () =>
|
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
|
||||||
Effect.gen(function* () {
|
const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 })
|
||||||
expect(Fff.available()).toBe(true)
|
expect(result.map((item) => item.path)).toEqual([RelativePath.make(path.join("src", "match.ts"))])
|
||||||
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")
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("keeps fuzzy file abbreviation matches", () =>
|
it.live("greps files with include filtering", () =>
|
||||||
Effect.gen(function* () {
|
withTmp((cwd) =>
|
||||||
expect(Fff.available()).toBe(true)
|
Effect.gen(function* () {
|
||||||
const dir = yield* tmpdir()
|
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
|
||||||
yield* write(path.join(dir, "README.md"), "hello\n")
|
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 search = yield* Search.Service
|
const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 })
|
||||||
yield* waitForFileIndex(search, dir)
|
expect(result).toHaveLength(1)
|
||||||
const results = yield* search.file({ cwd: dir, query: "rdme", limit: 10 })
|
expect(result[0]?.entry.path).toBe(RelativePath.make(path.join("src", "match.ts")))
|
||||||
|
expect(result[0]?.submatches[0]?.text).toBe("needle")
|
||||||
expect(results).toContainEqual({ path: "README.md", type: "file" })
|
}),
|
||||||
}),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
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 { describe, expect } from "bun:test"
|
||||||
import { Effect, Exit, Layer } from "effect"
|
import { Effect, Exit, Layer } from "effect"
|
||||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { location } from "./fixture/location"
|
import { location } from "./fixture/location"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { it } from "./lib/effect"
|
import { it } from "./lib/effect"
|
||||||
|
|
||||||
function provide(directory: string, search = Search.defaultLayer) {
|
const provide = (directory: string) =>
|
||||||
return Effect.provide(
|
Effect.provide(
|
||||||
FileSystem.layer.pipe(
|
FileSystem.layer.pipe(
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
FSUtil.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
search,
|
Ripgrep.defaultLayer,
|
||||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
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>) {
|
const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
||||||
return Effect.acquireRelease(
|
Effect.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||||
}
|
|
||||||
|
|
||||||
describe("FileSystem", () => {
|
describe("FileSystem", () => {
|
||||||
it.live("reads complete text and binary files", () =>
|
it.live("reads text and binary files", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const text = Array.from({ length: 3_000 }, (_, index) => `line-${index + 1}`).join("\n")
|
yield* Effect.promise(() => fs.writeFile(path.join(directory, "text.txt"), "hello"))
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "large.txt"), text))
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
|
yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
|
||||||
const service = yield* FileSystem.Service
|
const service = yield* FileSystem.Service
|
||||||
const textContent = yield* service.read({ path: RelativePath.make("large.txt") })
|
const text = yield* service.read({ path: RelativePath.make("text.txt") })
|
||||||
expect(textContent).toEqual({
|
const binary = yield* service.read({ path: RelativePath.make("data.bin") })
|
||||||
uri: textContent.uri,
|
expect(text).toMatchObject({ name: "text.txt", content: "hello", encoding: "utf8", mime: "text/plain" })
|
||||||
name: "large.txt",
|
expect(fileURLToPath(text.uri)).toBe(path.join(directory, "text.txt"))
|
||||||
content: text,
|
expect(binary).toMatchObject({ name: "data.bin", content: "AAEC", encoding: "base64" })
|
||||||
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"))
|
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("lists direct children with relative paths and resolved URIs", () =>
|
it.live("lists direct children", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
|
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
|
||||||
const entries = yield* (yield* FileSystem.Service).list()
|
const entries = yield* (yield* FileSystem.Service).list()
|
||||||
expect(entries.map(({ uri: _uri, ...entry }) => entry)).toEqual([
|
expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
|
||||||
{ path: RelativePath.make("src"), type: "directory", mime: "application/x-directory" },
|
{ path: RelativePath.make("src" + path.sep), type: "directory" },
|
||||||
{ path: RelativePath.make("README.md"), type: "file", mime: "text/markdown" },
|
{ 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)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("rejects lexical and symlink escapes", () =>
|
it.live("rejects lexical escapes", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* FileSystem.Service
|
const result = yield* (yield* FileSystem.Service)
|
||||||
expect(
|
.read({ path: RelativePath.make("../outside.txt") })
|
||||||
Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
|
.pipe(Effect.exit)
|
||||||
).toBe(true)
|
expect(Exit.isFailure(result)).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 }))
|
|
||||||
}).pipe(provide(directory)),
|
}).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 { Npm } from "../src/npm"
|
||||||
import { Project } from "../src/project"
|
import { Project } from "../src/project"
|
||||||
import { Reference } from "../src/reference"
|
import { Reference } from "../src/reference"
|
||||||
import { LocationSearch } from "../src/location-search"
|
|
||||||
import { ToolRegistry } from "../src/tool/registry"
|
import { ToolRegistry } from "../src/tool/registry"
|
||||||
import { ApplicationTools } from "../src/tool/application-tools"
|
import { ApplicationTools } from "../src/tool/application-tools"
|
||||||
|
|
||||||
@@ -28,6 +27,7 @@ const it = testEffect(
|
|||||||
Layer.merge(
|
Layer.merge(
|
||||||
applicationTools,
|
applicationTools,
|
||||||
LocationServiceMap.layer.pipe(
|
LocationServiceMap.layer.pipe(
|
||||||
|
Layer.provide(applicationTools),
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
@@ -72,7 +72,6 @@ describe("LocationServiceMap", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* PluginBoot.Service.use((boot) => boot.wait())
|
yield* PluginBoot.Service.use((boot) => boot.wait())
|
||||||
yield* Reference.Service
|
yield* Reference.Service
|
||||||
yield* LocationSearch.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const transform = yield* catalog.transform()
|
const transform = yield* catalog.transform()
|
||||||
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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)))
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -78,6 +78,7 @@
|
|||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@effect/opentelemetry": "catalog:",
|
"@effect/opentelemetry": "catalog:",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
|
"@ff-labs/fff-bun": "0.9.3",
|
||||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Fff } from "@opencode-ai/core/filesystem/fff.bun"
|
import { Fff } from "@opencode-ai/core/filesystem/fff.bun"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { InstanceStore } from "@/project/instance-store"
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
|
|
||||||
const dir = process.cwd()
|
const dir = AbsolutePath.make(process.cwd())
|
||||||
|
|
||||||
const FILE_QUERIES = ["fff", "package.json", "tools/ experiment"]
|
const FILE_QUERIES = ["fff", "package.json", "tools/ experiment"]
|
||||||
const GREP_QUERIES = ["FileFinder", "import", "grep", "autocomplete"]
|
const GREP_QUERIES = ["FileFinder", "import", "grep", "autocomplete"]
|
||||||
@@ -14,7 +15,7 @@ const FILE_LIMIT = 100
|
|||||||
const GREP_LIMIT = 50
|
const GREP_LIMIT = 50
|
||||||
const GLOB_LIMIT = 50
|
const GLOB_LIMIT = 50
|
||||||
|
|
||||||
const run = <A>(effect: Effect.Effect<A, unknown, Search.Service>) =>
|
const run = <A, R>(effect: Effect.Effect<A, unknown, R>) =>
|
||||||
AppRuntime.runPromise(
|
AppRuntime.runPromise(
|
||||||
InstanceStore.Service.use((store) => store.provide({ directory: dir }, effect as never)),
|
InstanceStore.Service.use((store) => store.provide({ directory: dir }, effect as never)),
|
||||||
) as Promise<A>
|
) as Promise<A>
|
||||||
@@ -57,32 +58,16 @@ for (const q of GREP_QUERIES) {
|
|||||||
|
|
||||||
picker.destroy()
|
picker.destroy()
|
||||||
|
|
||||||
// --- Ripgrep service (via Search with file:["."] to force rg path) ---
|
|
||||||
console.log()
|
|
||||||
console.log("--- Ripgrep (via Search service) ---")
|
|
||||||
|
|
||||||
// warmup
|
|
||||||
await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: "_warmup_rg_", limit: 1, file: ["."] })))
|
|
||||||
|
|
||||||
for (const q of GREP_QUERIES) {
|
|
||||||
const t = performance.now()
|
|
||||||
const r = await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: q, limit: GREP_LIMIT, file: ["."] })))
|
|
||||||
console.log(
|
|
||||||
`[ripgrep] grep "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.items.length} total, limit is per-file not total)`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Search service: init breakdown ---
|
// --- Search service: init breakdown ---
|
||||||
console.log()
|
console.log()
|
||||||
|
|
||||||
// 1) runtime + InstanceState + picker create + scan poll
|
// 1) runtime + InstanceState + picker create + scan poll
|
||||||
const tRuntime = performance.now()
|
const tRuntime = performance.now()
|
||||||
await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: "_warmup_file_", limit: 1 })))
|
|
||||||
console.log(`[Search] init file (runtime + picker + scan): ${(performance.now() - tRuntime).toFixed(1)}ms`)
|
console.log(`[Search] init file (runtime + picker + scan): ${(performance.now() - tRuntime).toFixed(1)}ms`)
|
||||||
|
|
||||||
// 2) grep warmup (content index cold-start inside the Search service picker)
|
// 2) grep warmup (content index cold-start inside the Search service picker)
|
||||||
const tGrepWarmup = performance.now()
|
const tGrepWarmup = performance.now()
|
||||||
await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: "_warmup_grep_", limit: 1 })))
|
await run(FileSystem.Service.use((svc) => svc.grep({ pattern: "_warmup_grep_", limit: 1 })))
|
||||||
console.log(`[Search] init grep (content index warmup): ${(performance.now() - tGrepWarmup).toFixed(1)}ms`)
|
console.log(`[Search] init grep (content index warmup): ${(performance.now() - tGrepWarmup).toFixed(1)}ms`)
|
||||||
|
|
||||||
console.log()
|
console.log()
|
||||||
@@ -90,24 +75,20 @@ console.log("--- Search service (warm) ---")
|
|||||||
|
|
||||||
for (const q of FILE_QUERIES) {
|
for (const q of FILE_QUERIES) {
|
||||||
const t = performance.now()
|
const t = performance.now()
|
||||||
const r = await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: q, limit: FILE_LIMIT })))
|
const r = await run(FileSystem.Service.use((svc) => svc.find({ query: q, limit: FILE_LIMIT })))
|
||||||
console.log(`[Search.file] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} results)`)
|
console.log(`[Search.find] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} results)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const q of GREP_QUERIES) {
|
for (const q of GREP_QUERIES) {
|
||||||
const t = performance.now()
|
const t = performance.now()
|
||||||
const r = await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: q, limit: GREP_LIMIT })))
|
const r = await run(FileSystem.Service.use((svc) => svc.grep({ pattern: q, limit: GREP_LIMIT })))
|
||||||
console.log(
|
console.log(`[Search.grep] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} matches)`)
|
||||||
`[Search.search] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.items.length} matches, engine=${r.engine})`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const q of GLOB_QUERIES) {
|
for (const q of GLOB_QUERIES) {
|
||||||
const t = performance.now()
|
const t = performance.now()
|
||||||
const r = await run(Search.Service.use((svc) => svc.glob({ cwd: dir, pattern: q, limit: GLOB_LIMIT })))
|
const r = await run(FileSystem.Service.use((svc) => svc.glob({ pattern: q, limit: GLOB_LIMIT })))
|
||||||
console.log(
|
console.log(`[Search.glob] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} files)`)
|
||||||
`[Search.glob] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.files.length} files, truncated=${r.truncated})`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ const binaries: Record<string, string> = {}
|
|||||||
if (!skipInstall) {
|
if (!skipInstall) {
|
||||||
await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||||
await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}`
|
await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}`
|
||||||
|
await $`bun install --os="*" --cpu="*" @ff-labs/fff-bun@${pkg.dependencies["@ff-labs/fff-bun"]}`
|
||||||
}
|
}
|
||||||
for (const item of targets) {
|
for (const item of targets) {
|
||||||
const name = [
|
const name = [
|
||||||
@@ -165,7 +166,7 @@ for (const item of targets) {
|
|||||||
const workerRelativePath = path.relative(dir, parserWorker).replaceAll("\\", "/")
|
const workerRelativePath = path.relative(dir, parserWorker).replaceAll("\\", "/")
|
||||||
|
|
||||||
await Bun.build({
|
await Bun.build({
|
||||||
conditions: ["node"],
|
conditions: ["bun", "node"],
|
||||||
tsconfig: "./tsconfig.json",
|
tsconfig: "./tsconfig.json",
|
||||||
plugins: [plugin],
|
plugins: [plugin],
|
||||||
external: ["node-gyp"],
|
external: ["node-gyp"],
|
||||||
@@ -186,6 +187,7 @@ for (const item of targets) {
|
|||||||
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
|
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
|
||||||
entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
|
entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
|
||||||
define: {
|
define: {
|
||||||
|
FFF_LIBC: JSON.stringify(item.abi === "musl" ? "musl" : "gnu"),
|
||||||
OPENCODE_VERSION: `'${Script.version}'`,
|
OPENCODE_VERSION: `'${Script.version}'`,
|
||||||
OPENCODE_MODELS_DEV: generated.modelsData,
|
OPENCODE_MODELS_DEV: generated.modelsData,
|
||||||
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
|
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { EOL } from "os"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
|
||||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { effectCmd } from "../../effect-cmd"
|
import { effectCmd } from "../../effect-cmd"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
@@ -23,7 +22,11 @@ const FileSearchCommand = effectCmd({
|
|||||||
description: "Search query",
|
description: "Search query",
|
||||||
}),
|
}),
|
||||||
handler: Effect.fn("Cli.debug.file.search")(function* (args) {
|
handler: Effect.fn("Cli.debug.file.search")(function* (args) {
|
||||||
const results = yield* filesystem(FileSystem.Service.use((svc) => svc.find({ query: args.query })))
|
const results = yield* Effect.orDie(
|
||||||
|
filesystem(
|
||||||
|
FileSystem.Service.use((svc) => svc.find({ query: args.query })),
|
||||||
|
),
|
||||||
|
)
|
||||||
process.stdout.write(results.map((item) => item.path).join(EOL) + EOL)
|
process.stdout.write(results.map((item) => item.path).join(EOL) + EOL)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -58,21 +61,6 @@ const FileListCommand = effectCmd({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const FileTreeCommand = effectCmd({
|
|
||||||
command: "tree [dir]",
|
|
||||||
describe: "show directory tree",
|
|
||||||
builder: (yargs) =>
|
|
||||||
yargs.positional("dir", {
|
|
||||||
type: "string",
|
|
||||||
description: "Directory to tree",
|
|
||||||
default: process.cwd(),
|
|
||||||
}),
|
|
||||||
handler: Effect.fn("Cli.debug.file.tree")(function* (args) {
|
|
||||||
const tree = yield* Effect.orDie(Search.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
|
|
||||||
console.log(JSON.stringify(tree, null, 2))
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const FileCommand = cmd({
|
export const FileCommand = cmd({
|
||||||
command: "file",
|
command: "file",
|
||||||
describe: "file system debugging utilities",
|
describe: "file system debugging utilities",
|
||||||
@@ -81,7 +69,6 @@ export const FileCommand = cmd({
|
|||||||
.command(FileReadCommand)
|
.command(FileReadCommand)
|
||||||
.command(FileListCommand)
|
.command(FileListCommand)
|
||||||
.command(FileSearchCommand)
|
.command(FileSearchCommand)
|
||||||
.command(FileTreeCommand)
|
|
||||||
.demandCommand(),
|
.demandCommand(),
|
||||||
async handler() {},
|
async handler() {},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { effectCmd } from "../../effect-cmd"
|
import { effectCmd } from "../../effect-cmd"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
import { InstanceRef } from "@/effect/instance-ref"
|
import { InstanceRef } from "@/effect/instance-ref"
|
||||||
@@ -8,25 +8,10 @@ import { InstanceRef } from "@/effect/instance-ref"
|
|||||||
export const RipgrepCommand = cmd({
|
export const RipgrepCommand = cmd({
|
||||||
command: "rg",
|
command: "rg",
|
||||||
describe: "ripgrep debugging utilities",
|
describe: "ripgrep debugging utilities",
|
||||||
builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(SearchCommand).demandCommand(),
|
builder: (yargs) => yargs.command(FilesCommand).command(SearchCommand).demandCommand(),
|
||||||
async handler() {},
|
async handler() {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const TreeCommand = effectCmd({
|
|
||||||
command: "tree",
|
|
||||||
describe: "show file tree using ripgrep",
|
|
||||||
builder: (yargs) =>
|
|
||||||
yargs.option("limit", {
|
|
||||||
type: "number",
|
|
||||||
}),
|
|
||||||
handler: Effect.fn("Cli.debug.rg.tree")(function* (args) {
|
|
||||||
const ctx = yield* InstanceRef
|
|
||||||
if (!ctx) return
|
|
||||||
const tree = yield* Effect.orDie(Search.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })))
|
|
||||||
process.stdout.write(tree + EOL)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const FilesCommand = effectCmd({
|
const FilesCommand = effectCmd({
|
||||||
command: "files",
|
command: "files",
|
||||||
describe: "list files using ripgrep",
|
describe: "list files using ripgrep",
|
||||||
@@ -47,19 +32,15 @@ const FilesCommand = effectCmd({
|
|||||||
handler: Effect.fn("Cli.debug.rg.files")(function* (args) {
|
handler: Effect.fn("Cli.debug.rg.files")(function* (args) {
|
||||||
const ctx = yield* InstanceRef
|
const ctx = yield* InstanceRef
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
const search = yield* Search.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
const files = yield* search
|
const files = yield* ripgrep
|
||||||
.files({
|
.glob({
|
||||||
cwd: ctx.directory,
|
cwd: ctx.directory,
|
||||||
glob: args.glob ? [args.glob] : undefined,
|
pattern: args.glob ?? "**/*",
|
||||||
|
limit: args.limit ?? 10_000,
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(Effect.orDie)
|
||||||
Stream.take(args.limit ?? Infinity),
|
process.stdout.write(files.map((file) => file.path).join(EOL) + EOL)
|
||||||
Stream.runCollect,
|
|
||||||
Effect.map((c) => [...c]),
|
|
||||||
Effect.orDie,
|
|
||||||
)
|
|
||||||
process.stdout.write(files.join(EOL) + EOL)
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -84,16 +65,15 @@ const SearchCommand = effectCmd({
|
|||||||
handler: Effect.fn("Cli.debug.rg.search")(function* (args) {
|
handler: Effect.fn("Cli.debug.rg.search")(function* (args) {
|
||||||
const ctx = yield* InstanceRef
|
const ctx = yield* InstanceRef
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
const results = yield* Effect.orDie(
|
const ripgrep = yield* Ripgrep.Service
|
||||||
Search.Service.use((svc) =>
|
const results = yield* ripgrep
|
||||||
svc.search({
|
.grep({
|
||||||
cwd: ctx.directory,
|
cwd: ctx.directory,
|
||||||
pattern: args.pattern,
|
pattern: args.pattern,
|
||||||
glob: args.glob as string[] | undefined,
|
include: args.glob?.[0],
|
||||||
limit: args.limit,
|
limit: args.limit ?? 10_000,
|
||||||
}),
|
})
|
||||||
),
|
.pipe(Effect.orDie)
|
||||||
)
|
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||||
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import { Auth } from "@/auth"
|
|||||||
import { Account } from "@/account/account"
|
import { Account } from "@/account/account"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
|
||||||
import { Storage } from "@/storage/storage"
|
import { Storage } from "@/storage/storage"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
@@ -61,8 +60,6 @@ export const AppLayer = Layer.mergeAll(
|
|||||||
Account.defaultLayer,
|
Account.defaultLayer,
|
||||||
Config.defaultLayer,
|
Config.defaultLayer,
|
||||||
Git.defaultLayer,
|
Git.defaultLayer,
|
||||||
Ripgrep.defaultLayer,
|
|
||||||
Search.defaultLayer,
|
|
||||||
Storage.defaultLayer,
|
Storage.defaultLayer,
|
||||||
Snapshot.defaultLayer,
|
Snapshot.defaultLayer,
|
||||||
Plugin.defaultLayer,
|
Plugin.defaultLayer,
|
||||||
@@ -102,7 +99,11 @@ export const AppLayer = Layer.mergeAll(
|
|||||||
Installation.defaultLayer,
|
Installation.defaultLayer,
|
||||||
ShareNext.defaultLayer,
|
ShareNext.defaultLayer,
|
||||||
SessionShare.defaultLayer,
|
SessionShare.defaultLayer,
|
||||||
).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer))
|
).pipe(
|
||||||
|
Layer.provideMerge(Ripgrep.defaultLayer),
|
||||||
|
Layer.provideMerge(InstanceLayer.layer),
|
||||||
|
Layer.provideMerge(Observability.layer),
|
||||||
|
)
|
||||||
|
|
||||||
const rt = ManagedRuntime.make(AppLayer, { memoMap })
|
const rt = ManagedRuntime.make(AppLayer, { memoMap })
|
||||||
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
|
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import { Snapshot } from "../snapshot"
|
|||||||
import * as Project from "./project"
|
import * as Project from "./project"
|
||||||
import * as Vcs from "./vcs"
|
import * as Vcs from "./vcs"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { registerDisposer } from "@/effect/instance-registry"
|
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Service } from "./bootstrap-service"
|
import { Service } from "./bootstrap-service"
|
||||||
@@ -27,25 +25,15 @@ export const layer = Layer.effect(
|
|||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const project = yield* Project.Service
|
const project = yield* Project.Service
|
||||||
const search = yield* Search.Service
|
|
||||||
const shareNext = yield* ShareNext.Service
|
const shareNext = yield* ShareNext.Service
|
||||||
const snapshot = yield* Snapshot.Service
|
const snapshot = yield* Snapshot.Service
|
||||||
const vcs = yield* Vcs.Service
|
const vcs = yield* Vcs.Service
|
||||||
|
|
||||||
// once we dispose the service - also release all the internal fff resources
|
|
||||||
const off = registerDisposer((directory) => Effect.runPromise(search.release(directory)))
|
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
|
||||||
|
|
||||||
const run = Effect.gen(function* () {
|
const run = Effect.gen(function* () {
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
|
yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
|
||||||
// everything depends on config so eager load it for nice traces
|
// everything depends on config so eager load it for nice traces
|
||||||
yield* config.get()
|
yield* config.get()
|
||||||
// in 99% of use cases user that is opened opencode at certain directory will
|
|
||||||
// conduct a file search in this direcotry, it could be switched later but
|
|
||||||
// mostly always we will need a file picker for cwd
|
|
||||||
// so synchronously start FFF scan for a cwd so it is ready before first toolcall generated
|
|
||||||
yield* search.warm(ctx.directory).pipe(Effect.ignore)
|
|
||||||
// Plugin can mutate config so it has to be initialized before anything else.
|
// Plugin can mutate config so it has to be initialized before anything else.
|
||||||
yield* plugin.init()
|
yield* plugin.init()
|
||||||
// Each service self-manages its own slow work via Effect.forkScoped against
|
// Each service self-manages its own slow work via Effect.forkScoped against
|
||||||
@@ -68,7 +56,6 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
|||||||
LSP.defaultLayer,
|
LSP.defaultLayer,
|
||||||
Plugin.defaultLayer,
|
Plugin.defaultLayer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
Search.defaultLayer,
|
|
||||||
ShareNext.defaultLayer,
|
ShareNext.defaultLayer,
|
||||||
Snapshot.defaultLayer,
|
Snapshot.defaultLayer,
|
||||||
Vcs.defaultLayer,
|
Vcs.defaultLayer,
|
||||||
@@ -81,7 +68,6 @@ export const node = LayerNode.make(layer, [
|
|||||||
LSP.node,
|
LSP.node,
|
||||||
Plugin.node,
|
Plugin.node,
|
||||||
Project.node,
|
Project.node,
|
||||||
Search.node,
|
|
||||||
ShareNext.node,
|
ShareNext.node,
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
Vcs.node,
|
Vcs.node,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
|
||||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
@@ -38,6 +37,20 @@ export const FindSymbolQuery = Schema.Struct({
|
|||||||
query: Schema.String,
|
query: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const LegacyMatch = Schema.Struct({
|
||||||
|
path: Schema.Struct({ text: Schema.String }),
|
||||||
|
lines: Schema.Struct({ text: Schema.String }),
|
||||||
|
line_number: NonNegativeInt,
|
||||||
|
absolute_offset: NonNegativeInt,
|
||||||
|
submatches: Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
match: Schema.Struct({ text: Schema.String }),
|
||||||
|
start: NonNegativeInt,
|
||||||
|
end: NonNegativeInt,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
export const LegacyEntry = Schema.Struct({
|
export const LegacyEntry = Schema.Struct({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
path: Schema.String,
|
path: Schema.String,
|
||||||
@@ -94,7 +107,7 @@ export const FileApi = HttpApi.make("file")
|
|||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("findText", FilePaths.findText, {
|
HttpApiEndpoint.get("findText", FilePaths.findText, {
|
||||||
query: FindTextQuery,
|
query: FindTextQuery,
|
||||||
success: described(Schema.Array(Ripgrep.SearchMatch), "Matches"),
|
success: described(Schema.Array(LegacyMatch), "Matches"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "find.text",
|
identifier: "find.text",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import * as InstanceState from "@/effect/instance-state"
|
import * as InstanceState from "@/effect/instance-state"
|
||||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
@@ -15,7 +14,6 @@ import { InstanceHttpApi } from "../api"
|
|||||||
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
|
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const ripgrep = yield* Ripgrep.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
const search = yield* Search.Service
|
|
||||||
const locations = yield* LocationServiceMap
|
const locations = yield* LocationServiceMap
|
||||||
|
|
||||||
const filesystem = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
|
const filesystem = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||||
@@ -26,8 +24,18 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
|||||||
|
|
||||||
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
|
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
|
||||||
return (yield* ripgrep
|
return (yield* ripgrep
|
||||||
.search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 })
|
.grep({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 })
|
||||||
.pipe(Effect.orDie)).items
|
.pipe(Effect.orDie)).map((match) => ({
|
||||||
|
path: { text: match.entry.path },
|
||||||
|
lines: { text: match.text },
|
||||||
|
line_number: match.line,
|
||||||
|
absolute_offset: match.offset,
|
||||||
|
submatches: match.submatches.map((submatch) => ({
|
||||||
|
match: { text: submatch.text },
|
||||||
|
start: submatch.start,
|
||||||
|
end: submatch.end,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
|
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
|
||||||
@@ -35,19 +43,18 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
|||||||
}) {
|
}) {
|
||||||
const directory = (yield* InstanceState.context).directory
|
const directory = (yield* InstanceState.context).directory
|
||||||
const limit = ctx.query.limit ?? 10
|
const limit = ctx.query.limit ?? 10
|
||||||
const kind = ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : "all")
|
const type = ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : undefined)
|
||||||
const started = performance.now()
|
const started = performance.now()
|
||||||
const fff = yield* search.file({ cwd: directory, query: ctx.query.query, limit, kind }).pipe(Effect.orDie)
|
const found = yield* filesystem(FileSystem.Service.use((fs) => fs.find({ query: ctx.query.query, limit, type })))
|
||||||
yield* Effect.logInfo("find file", {
|
yield* Effect.logInfo("find file", {
|
||||||
engine: "fff",
|
|
||||||
query: ctx.query.query,
|
query: ctx.query.query,
|
||||||
kind,
|
type,
|
||||||
directory,
|
directory,
|
||||||
limit,
|
limit,
|
||||||
results: fff.length,
|
results: found.length,
|
||||||
duration: Math.round(performance.now() - started),
|
duration: Math.round(performance.now() - started),
|
||||||
})
|
})
|
||||||
return fff.map((item) => item.path)
|
return found.map((item) => item.path)
|
||||||
})
|
})
|
||||||
|
|
||||||
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
|
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
|
||||||
@@ -73,10 +80,10 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
|||||||
return (yield* fs.list({ path: RelativePath.make(ctx.query.path) })).map((item) => ({
|
return (yield* fs.list({ path: RelativePath.make(ctx.query.path) })).map((item) => ({
|
||||||
name: path.basename(item.path),
|
name: path.basename(item.path),
|
||||||
path: item.path,
|
path: item.path,
|
||||||
absolute: path.join(directory, item.path),
|
absolute: path.resolve(location.directory, item.path),
|
||||||
type: item.type,
|
type: item.type,
|
||||||
ignored: ignored.ignores(
|
ignored: ignored.ignores(
|
||||||
path.relative(location.project.directory, path.join(location.directory, item.path)) +
|
path.relative(location.project.directory, path.resolve(location.directory, item.path)) +
|
||||||
(item.type === "directory" ? "/" : ""),
|
(item.type === "directory" ? "/" : ""),
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
@@ -112,4 +119,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
|||||||
.handle("content", content)
|
.handle("content", content)
|
||||||
.handle("status", status)
|
.handle("status", status)
|
||||||
}),
|
}),
|
||||||
).pipe(Layer.provide(LocationServiceMap.layer), Layer.provide(Search.defaultLayer))
|
).pipe(Layer.provide(LocationServiceMap.layer))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { BackgroundJob } from "@/background/job"
|
|||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Command } from "@/command"
|
import { Command } from "@/command"
|
||||||
import * as Observability from "@opencode-ai/core/observability"
|
import * as Observability from "@opencode-ai/core/observability"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { Format } from "@/format"
|
import { Format } from "@/format"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
@@ -234,7 +234,6 @@ export function createRoutes(
|
|||||||
Provider.defaultLayer,
|
Provider.defaultLayer,
|
||||||
PtyTicket.defaultLayer,
|
PtyTicket.defaultLayer,
|
||||||
Question.defaultLayer,
|
Question.defaultLayer,
|
||||||
Ripgrep.defaultLayer,
|
|
||||||
RuntimeFlags.defaultLayer,
|
RuntimeFlags.defaultLayer,
|
||||||
Session.defaultLayer,
|
Session.defaultLayer,
|
||||||
SessionCompaction.defaultLayer,
|
SessionCompaction.defaultLayer,
|
||||||
@@ -259,6 +258,7 @@ export function createRoutes(
|
|||||||
HttpServer.layerServices,
|
HttpServer.layerServices,
|
||||||
]),
|
]),
|
||||||
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
|
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
|
||||||
|
Layer.provideMerge(Ripgrep.defaultLayer),
|
||||||
Layer.provide(InstanceLayer.layer),
|
Layer.provide(InstanceLayer.layer),
|
||||||
Layer.provideMerge(Observability.layer),
|
Layer.provideMerge(Observability.layer),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
|
|||||||
target?: string,
|
target?: string,
|
||||||
options?: Options,
|
options?: Options,
|
||||||
) {
|
) {
|
||||||
if (!target) return
|
if (!target) return false
|
||||||
|
|
||||||
if (options?.bypass) return
|
if (options?.bypass) return false
|
||||||
|
|
||||||
const ins = yield* InstanceState.context
|
const ins = yield* InstanceState.context
|
||||||
const full = process.platform === "win32" ? FSUtil.normalizePath(target) : target
|
const full = process.platform === "win32" ? FSUtil.normalizePath(target) : target
|
||||||
if (containsPath(full, ins)) return
|
if (containsPath(full, ins)) return false
|
||||||
|
|
||||||
const kind = options?.kind ?? "file"
|
const kind = options?.kind ?? "file"
|
||||||
const dir = kind === "directory" ? full : path.dirname(full)
|
const dir = kind === "directory" ? full : path.dirname(full)
|
||||||
@@ -41,6 +41,7 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
|
|||||||
parentDir: dir,
|
parentDir: dir,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
|
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path"
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
import DESCRIPTION from "./glob.txt"
|
import DESCRIPTION from "./glob.txt"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
@@ -18,8 +18,7 @@ export const GlobTool = Tool.define(
|
|||||||
"glob",
|
"glob",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const searchSvc = yield* Search.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: Parameters,
|
parameters: Parameters,
|
||||||
@@ -48,18 +47,14 @@ export const GlobTool = Tool.define(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const limit = 100
|
const limit = 100
|
||||||
const files = yield* searchSvc.glob({
|
const files = yield* ripgrep.glob({ cwd: search, pattern: params.pattern, limit })
|
||||||
cwd: search,
|
const truncated = files.length === limit
|
||||||
pattern: params.pattern,
|
|
||||||
limit,
|
|
||||||
signal: ctx.abort,
|
|
||||||
})
|
|
||||||
|
|
||||||
const output = []
|
const output = []
|
||||||
if (files.files.length === 0) output.push("No files found")
|
if (files.length === 0) output.push("No files found")
|
||||||
if (files.files.length > 0) {
|
if (files.length > 0) {
|
||||||
output.push(...files.files)
|
output.push(...files.map((file) => path.resolve(search, file.path)))
|
||||||
if (files.truncated) {
|
if (truncated) {
|
||||||
output.push("")
|
output.push("")
|
||||||
output.push(
|
output.push(
|
||||||
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
|
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
|
||||||
@@ -70,8 +65,8 @@ export const GlobTool = Tool.define(
|
|||||||
return {
|
return {
|
||||||
title: path.relative(ins.worktree, search),
|
title: path.relative(ins.worktree, search),
|
||||||
metadata: {
|
metadata: {
|
||||||
count: files.files.length,
|
count: files.length,
|
||||||
truncated: files.truncated,
|
truncated,
|
||||||
},
|
},
|
||||||
output: output.join("\n"),
|
output: output.join("\n"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import path from "path"
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
import DESCRIPTION from "./grep.txt"
|
import DESCRIPTION from "./grep.txt"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
|
|
||||||
const MAX_LINE_LENGTH = 2000
|
|
||||||
|
|
||||||
export const Parameters = Schema.Struct({
|
export const Parameters = Schema.Struct({
|
||||||
pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }),
|
pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }),
|
||||||
path: Schema.optional(Schema.String).annotate({
|
path: Schema.optional(Schema.String).annotate({
|
||||||
@@ -23,8 +21,7 @@ export const GrepTool = Tool.define(
|
|||||||
"grep",
|
"grep",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const searchSvc = yield* Search.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: Parameters,
|
parameters: Parameters,
|
||||||
@@ -63,30 +60,27 @@ export const GrepTool = Tool.define(
|
|||||||
const search = FSUtil.resolve(requested)
|
const search = FSUtil.resolve(requested)
|
||||||
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
||||||
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
|
const result = yield* ripgrep.grep({
|
||||||
|
|
||||||
const result = yield* searchSvc.search({
|
|
||||||
cwd,
|
cwd,
|
||||||
pattern: params.pattern,
|
pattern: params.pattern,
|
||||||
glob: params.include ? [params.include] : undefined,
|
include: params.include,
|
||||||
file,
|
limit: 100,
|
||||||
signal: ctx.abort,
|
|
||||||
})
|
})
|
||||||
if (result.items.length === 0) return empty
|
if (result.length === 0) return empty
|
||||||
|
|
||||||
const rows = result.items.map((item) => ({
|
const rows = result.map((item) => ({
|
||||||
path: FSUtil.resolve(path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text)),
|
path: path.resolve(cwd, item.entry.path),
|
||||||
line: item.line_number,
|
line: item.line,
|
||||||
text: item.lines.text,
|
text: item.text,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const limit = 100
|
const limit = 100
|
||||||
const truncated = rows.length > limit
|
const truncated = rows.length === limit
|
||||||
const final = truncated ? rows.slice(0, limit) : rows
|
const final = rows
|
||||||
if (final.length === 0) return empty
|
if (final.length === 0) return empty
|
||||||
|
|
||||||
const total = rows.length
|
const total = rows.length
|
||||||
const hasMore = truncated || result.hasNextPage
|
const hasMore = truncated || result.length === limit
|
||||||
const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`]
|
const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`]
|
||||||
|
|
||||||
let current = ""
|
let current = ""
|
||||||
@@ -96,31 +90,12 @@ export const GrepTool = Tool.define(
|
|||||||
current = match.path
|
current = match.path
|
||||||
output.push(`${match.path}:`)
|
output.push(`${match.path}:`)
|
||||||
}
|
}
|
||||||
const text =
|
output.push(` Line ${match.line}: ${match.text}`)
|
||||||
match.text.length > MAX_LINE_LENGTH ? match.text.substring(0, MAX_LINE_LENGTH) + "..." : match.text
|
|
||||||
output.push(` Line ${match.line}: ${text}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (truncated) {
|
if (truncated) {
|
||||||
output.push("")
|
output.push("")
|
||||||
output.push(
|
output.push("(Results truncated. Consider using a more specific path or pattern.)")
|
||||||
`(Results truncated: showing ${limit} of ${total} matches (${total - limit} hidden). Consider using a more specific path or pattern.)`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.hasNextPage) {
|
|
||||||
output.push("")
|
|
||||||
output.push(`(Results truncated. Consider using a more specific path or pattern.)`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.partial) {
|
|
||||||
output.push("")
|
|
||||||
output.push("(Some paths were inaccessible and skipped)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.regexFallbackError) {
|
|
||||||
output.push("")
|
|
||||||
output.push(`(Regex fallback: ${result.regexFallbackError})`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import DESCRIPTION from "./read.txt"
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
import { Instruction } from "../session/instruction"
|
import { Instruction } from "../session/instruction"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
|
||||||
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
||||||
|
|
||||||
const DEFAULT_READ_LIMIT = 2000
|
const DEFAULT_READ_LIMIT = 2000
|
||||||
@@ -65,14 +64,13 @@ type Metadata = {
|
|||||||
export const ReadTool = Tool.define<
|
export const ReadTool = Tool.define<
|
||||||
typeof Parameters,
|
typeof Parameters,
|
||||||
Metadata,
|
Metadata,
|
||||||
FSUtil.Service | Instruction.Service | LSP.Service | Search.Service | Scope.Scope
|
FSUtil.Service | Instruction.Service | LSP.Service | Scope.Scope
|
||||||
>(
|
>(
|
||||||
"read",
|
"read",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const instruction = yield* Instruction.Service
|
const instruction = yield* Instruction.Service
|
||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
const search = yield* Search.Service
|
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
|
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
|
||||||
@@ -117,7 +115,6 @@ export const ReadTool = Tool.define<
|
|||||||
})
|
})
|
||||||
|
|
||||||
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
|
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
|
||||||
yield* search.open({ file: filepath }).pipe(Effect.ignore)
|
|
||||||
// LSP warm-up is optional; do not let a background defect fail an otherwise successful read.
|
// LSP warm-up is optional; do not let a background defect fail an otherwise successful read.
|
||||||
yield* lsp.touchFile(filepath).pipe(Effect.ignoreCause, Effect.forkIn(scope))
|
yield* lsp.touchFile(filepath).pipe(Effect.ignoreCause, Effect.forkIn(scope))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
|
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { PlanExitTool } from "./plan"
|
import { PlanExitTool } from "./plan"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { QuestionTool } from "./question"
|
import { QuestionTool } from "./question"
|
||||||
@@ -36,7 +36,6 @@ import { Effect, Layer, Context } from "effect"
|
|||||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
|
||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { EffectBridge } from "@/effect/bridge"
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
@@ -81,30 +80,7 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
|
||||||
|
|
||||||
export const layer: Layer.Layer<
|
export const layer = Layer.effect(
|
||||||
Service,
|
|
||||||
never,
|
|
||||||
| Config.Service
|
|
||||||
| Plugin.Service
|
|
||||||
| Question.Service
|
|
||||||
| Todo.Service
|
|
||||||
| Agent.Service
|
|
||||||
| Skill.Service
|
|
||||||
| Session.Service
|
|
||||||
| BackgroundJob.Service
|
|
||||||
| Provider.Service
|
|
||||||
| LSP.Service
|
|
||||||
| Instruction.Service
|
|
||||||
| FSUtil.Service
|
|
||||||
| EventV2Bridge.Service
|
|
||||||
| HttpClient.HttpClient
|
|
||||||
| ChildProcessSpawner
|
|
||||||
| Search.Service
|
|
||||||
| Format.Service
|
|
||||||
| Truncate.Service
|
|
||||||
| RuntimeFlags.Service
|
|
||||||
| Database.Service
|
|
||||||
> = Layer.effect(
|
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
@@ -358,7 +334,6 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(Format.defaultLayer),
|
Layer.provide(Format.defaultLayer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
Layer.provide(Search.defaultLayer),
|
|
||||||
Layer.provide(Truncate.defaultLayer),
|
Layer.provide(Truncate.defaultLayer),
|
||||||
)
|
)
|
||||||
.pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)),
|
.pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)),
|
||||||
@@ -440,7 +415,7 @@ function isJsonSchemaObject(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const node = LayerNode.make(layer, [
|
export const node = LayerNode.make(layer.pipe(Layer.provide(Ripgrep.defaultLayer)), [
|
||||||
Config.node,
|
Config.node,
|
||||||
Plugin.node,
|
Plugin.node,
|
||||||
Question.node,
|
Question.node,
|
||||||
@@ -456,8 +431,6 @@ export const node = LayerNode.make(layer, [
|
|||||||
EventV2Bridge.node,
|
EventV2Bridge.node,
|
||||||
httpClient,
|
httpClient,
|
||||||
CrossSpawnSpawner.node,
|
CrossSpawnSpawner.node,
|
||||||
Ripgrep.node,
|
|
||||||
Search.node,
|
|
||||||
Format.node,
|
Format.node,
|
||||||
Truncate.node,
|
Truncate.node,
|
||||||
RuntimeFlags.node,
|
RuntimeFlags.node,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
import DESCRIPTION from "./skill.txt"
|
import DESCRIPTION from "./skill.txt"
|
||||||
@@ -15,7 +14,7 @@ export const SkillTool = Tool.define(
|
|||||||
"skill",
|
"skill",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const skill = yield* Skill.Service
|
const skill = yield* Skill.Service
|
||||||
const searchSvc = yield* Search.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
@@ -35,14 +34,14 @@ export const SkillTool = Tool.define(
|
|||||||
|
|
||||||
const dir = path.dirname(info.location)
|
const dir = path.dirname(info.location)
|
||||||
const base = pathToFileURL(dir).href
|
const base = pathToFileURL(dir).href
|
||||||
const limit = 10
|
const files = yield* ripgrep.find({
|
||||||
const files = yield* searchSvc.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
|
cwd: dir,
|
||||||
Stream.filter((file) => !file.includes("SKILL.md")),
|
pattern: "!**/SKILL.md",
|
||||||
Stream.map((file) => path.resolve(dir, file)),
|
hidden: true,
|
||||||
Stream.take(limit),
|
follow: false,
|
||||||
Stream.runCollect,
|
signal: ctx.abort,
|
||||||
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
|
limit: 10,
|
||||||
)
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `Loaded skill: ${info.name}`,
|
title: `Loaded skill: ${info.name}`,
|
||||||
@@ -57,7 +56,7 @@ export const SkillTool = Tool.define(
|
|||||||
"Note: file list is sampled.",
|
"Note: file list is sampled.",
|
||||||
"",
|
"",
|
||||||
"<skill_files>",
|
"<skill_files>",
|
||||||
files,
|
files.map((file) => `<file>${path.resolve(dir, file.path)}</file>`).join("\n"),
|
||||||
"</skill_files>",
|
"</skill_files>",
|
||||||
"</skill_content>",
|
"</skill_content>",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { Project } from "@/project/project"
|
|||||||
import { Vcs } from "@/project/vcs"
|
import { Vcs } from "@/project/vcs"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
|
|
||||||
const originalEnv = {
|
const originalEnv = {
|
||||||
OPENCODE_AUTH_CONTENT: process.env.OPENCODE_AUTH_CONTENT,
|
OPENCODE_AUTH_CONTENT: process.env.OPENCODE_AUTH_CONTENT,
|
||||||
@@ -54,6 +55,7 @@ const workspaceLayer = (experimentalWorkspaces: boolean) =>
|
|||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })),
|
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })),
|
||||||
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))),
|
Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FetchHttpClient } from "effect/unstable/http"
|
|||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
@@ -55,7 +56,9 @@ const workspaceLayer = Workspace.layer.pipe(
|
|||||||
Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))),
|
Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))),
|
||||||
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })),
|
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })),
|
||||||
)
|
)
|
||||||
const it = testEffect(Layer.mergeAll(pluginLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer))
|
const it = testEffect(
|
||||||
|
Layer.mergeAll(pluginLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(Ripgrep.defaultLayer)),
|
||||||
|
)
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await disposeAllInstances()
|
await disposeAllInstances()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { mkdir } from "node:fs/promises"
|
|||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||||
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||||
import { Workspace } from "../../src/control-plane/workspace"
|
import { Workspace } from "../../src/control-plane/workspace"
|
||||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||||
@@ -53,7 +54,7 @@ const it = testEffect(
|
|||||||
InstanceLayer.layer,
|
InstanceLayer.layer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
workspaceLayer,
|
workspaceLayer,
|
||||||
),
|
).pipe(Layer.provide(Ripgrep.defaultLayer)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const instanceContextTestLayer = Layer.mergeAll(
|
const instanceContextTestLayer = Layer.mergeAll(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import * as Socket from "effect/unstable/socket/Socket"
|
|||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
||||||
import { mkdir } from "node:fs/promises"
|
import { mkdir } from "node:fs/promises"
|
||||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||||
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||||
import { Workspace } from "../../src/control-plane/workspace"
|
import { Workspace } from "../../src/control-plane/workspace"
|
||||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||||
@@ -58,7 +59,7 @@ const it = testEffect(
|
|||||||
InstanceLayer.layer,
|
InstanceLayer.layer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
workspaceLayer,
|
workspaceLayer,
|
||||||
),
|
).pipe(Layer.provide(Ripgrep.defaultLayer)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const instanceContextTestLayer = Layer.mergeAll(
|
const instanceContextTestLayer = Layer.mergeAll(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServ
|
|||||||
import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket"
|
import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||||
import { Workspace } from "../../src/control-plane/workspace"
|
import { Workspace } from "../../src/control-plane/workspace"
|
||||||
@@ -66,7 +67,7 @@ const it = testEffect(
|
|||||||
workspaceLayer,
|
workspaceLayer,
|
||||||
Database.defaultLayer,
|
Database.defaultLayer,
|
||||||
httpApiLayer,
|
httpApiLayer,
|
||||||
),
|
).pipe(Layer.provide(Ripgrep.defaultLayer)),
|
||||||
)
|
)
|
||||||
|
|
||||||
function pathFor(path: string, params: Record<string, string>) {
|
function pathFor(path: string, params: Record<string, string>) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
|||||||
import { Workspace } from "../../src/control-plane/workspace"
|
import { Workspace } from "../../src/control-plane/workspace"
|
||||||
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { Project } from "../../src/project/project"
|
import { Project } from "../../src/project/project"
|
||||||
import { Session } from "../../src/session/session"
|
import { Session } from "../../src/session/session"
|
||||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||||
@@ -58,7 +59,7 @@ const it = testEffect(
|
|||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
workspaceLayer,
|
workspaceLayer,
|
||||||
Socket.layerWebSocketConstructorGlobal,
|
Socket.layerWebSocketConstructorGlobal,
|
||||||
),
|
).pipe(Layer.provide(Ripgrep.defaultLayer)),
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProxiedRequest = {
|
type ProxiedRequest = {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/
|
|||||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { Server } from "../../src/server/server"
|
import { Server } from "../../src/server/server"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
@@ -34,7 +35,7 @@ const it = testEffect(
|
|||||||
InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)),
|
InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)),
|
||||||
Database.defaultLayer,
|
Database.defaultLayer,
|
||||||
httpApiLayer,
|
httpApiLayer,
|
||||||
),
|
).pipe(Layer.provide(Ripgrep.defaultLayer)),
|
||||||
)
|
)
|
||||||
|
|
||||||
function request(path: string, directory: string, init: RequestInit = {}) {
|
function request(path: string, directory: string, init: RequestInit = {}) {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import { Snapshot } from "../../src/snapshot"
|
|||||||
import { ToolRegistry } from "@/tool/registry"
|
import { ToolRegistry } from "@/tool/registry"
|
||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncate"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
import { TestInstance } from "../fixture/fixture"
|
import { TestInstance } from "../fixture/fixture"
|
||||||
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
|
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
|
||||||
@@ -190,7 +190,7 @@ function makePrompt(input?: { processor?: "blocking" }) {
|
|||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provide(Search.defaultLayer),
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
Layer.provide(Format.defaultLayer),
|
Layer.provide(Format.defaultLayer),
|
||||||
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||||
Layer.provideMerge(todo),
|
Layer.provideMerge(todo),
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ import { ToolRegistry } from "@/tool/registry"
|
|||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncate"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ function makeHttp() {
|
|||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provide(Search.defaultLayer),
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
Layer.provide(Format.defaultLayer),
|
Layer.provide(Format.defaultLayer),
|
||||||
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||||
Layer.provideMerge(todo),
|
Layer.provideMerge(todo),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||||
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { SessionPrompt } from "../../src/session/prompt"
|
import { SessionPrompt } from "../../src/session/prompt"
|
||||||
@@ -8,7 +9,9 @@ import { testEffect } from "../lib/effect"
|
|||||||
|
|
||||||
// Skip tests if no API key is available
|
// Skip tests if no API key is available
|
||||||
const hasApiKey = !!process.env.ANTHROPIC_API_KEY
|
const hasApiKey = !!process.env.ANTHROPIC_API_KEY
|
||||||
const it = testEffect(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))
|
const it = testEffect(
|
||||||
|
Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer).pipe(Layer.provide(Ripgrep.defaultLayer)),
|
||||||
|
)
|
||||||
const live = hasApiKey ? it.instance : it.instance.skip
|
const live = hasApiKey ? it.instance : it.instance.skip
|
||||||
|
|
||||||
describe("StructuredOutput Integration", () => {
|
describe("StructuredOutput Integration", () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Cause, Effect, Exit, Layer } from "effect"
|
|||||||
import { GlobTool } from "../../src/tool/glob"
|
import { GlobTool } from "../../src/tool/glob"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncate"
|
||||||
@@ -23,7 +23,7 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
FSUtil.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
Search.defaultLayer,
|
Ripgrep.defaultLayer,
|
||||||
Truncate.defaultLayer,
|
Truncate.defaultLayer,
|
||||||
Agent.defaultLayer,
|
Agent.defaultLayer,
|
||||||
Git.defaultLayer,
|
Git.defaultLayer,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncate"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
@@ -25,7 +25,7 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
FSUtil.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
Search.defaultLayer,
|
Ripgrep.defaultLayer,
|
||||||
Truncate.defaultLayer,
|
Truncate.defaultLayer,
|
||||||
Agent.defaultLayer,
|
Agent.defaultLayer,
|
||||||
Git.defaultLayer,
|
Git.defaultLayer,
|
||||||
@@ -135,6 +135,25 @@ describe("tool.grep", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.instance("does not report an unknown total when results are truncated", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const test = yield* TestInstance
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Promise.all(
|
||||||
|
Array.from({ length: 101 }, (_, index) =>
|
||||||
|
Bun.write(path.join(test.directory, `match-${index}.txt`), "needle"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const info = yield* GrepTool
|
||||||
|
const grep = yield* info.init()
|
||||||
|
const result = yield* grep.execute({ pattern: "needle", path: test.directory, include: "*.txt" }, ctx)
|
||||||
|
|
||||||
|
expect(result.output).toContain("(Results truncated. Consider using a more specific path or pattern.)")
|
||||||
|
expect(result.output).not.toMatch(/showing \d+ of \d+ matches/)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.instance("supports exact file paths", () =>
|
it.instance("supports exact file paths", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const test = yield* TestInstance
|
const test = yield* TestInstance
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
|||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
@@ -50,7 +50,7 @@ const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
Instruction.defaultLayer,
|
Instruction.defaultLayer,
|
||||||
LSP.defaultLayer,
|
LSP.defaultLayer,
|
||||||
Search.defaultLayer,
|
Ripgrep.defaultLayer,
|
||||||
Truncate.defaultLayer,
|
Truncate.defaultLayer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { Instruction } from "@/session/instruction"
|
|||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import { Format } from "@/format"
|
import { Format } from "@/format"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import * as Truncate from "@/tool/truncate"
|
import * as Truncate from "@/tool/truncate"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ const registryLayer = (opts: RegistryLayerOptions = {}) =>
|
|||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(Format.defaultLayer),
|
Layer.provide(Format.defaultLayer),
|
||||||
Layer.provide(Layer.mergeAll(node, Database.defaultLayer)),
|
Layer.provide(Layer.mergeAll(node, Database.defaultLayer)),
|
||||||
Layer.provide(Search.defaultLayer),
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
Layer.provide(Truncate.defaultLayer),
|
Layer.provide(Truncate.defaultLayer),
|
||||||
)
|
)
|
||||||
.pipe(Layer.provide(RuntimeFlags.layer(opts.flags ?? {})))
|
.pipe(Layer.provide(RuntimeFlags.layer(opts.flags ?? {})))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { Cause, Effect, Exit, Layer } from "effect"
|
import { Cause, Effect, Exit, Layer } from "effect"
|
||||||
import { afterEach, describe, expect } from "bun:test"
|
import { afterEach, describe, expect } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
@@ -28,7 +29,7 @@ afterEach(async () => {
|
|||||||
|
|
||||||
const node = CrossSpawnSpawner.defaultLayer
|
const node = CrossSpawnSpawner.defaultLayer
|
||||||
|
|
||||||
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
|
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node).pipe(Layer.provide(Ripgrep.defaultLayer)))
|
||||||
|
|
||||||
describe("tool.skill", () => {
|
describe("tool.skill", () => {
|
||||||
it.instance("execute returns skill content block with files", () =>
|
it.instance("execute returns skill content block with files", () =>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { BackgroundJob } from "@/background/job"
|
|||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import type { SessionPrompt } from "../../src/session/prompt"
|
import type { SessionPrompt } from "../../src/session/prompt"
|
||||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||||
@@ -45,7 +46,7 @@ const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||||||
ToolRegistry.defaultLayer,
|
ToolRegistry.defaultLayer,
|
||||||
Database.defaultLayer,
|
Database.defaultLayer,
|
||||||
RuntimeFlags.layer(flags),
|
RuntimeFlags.layer(flags),
|
||||||
)
|
).pipe(Layer.provide(Ripgrep.defaultLayer))
|
||||||
|
|
||||||
const it = testEffect(layer())
|
const it = testEffect(layer())
|
||||||
const background = testEffect(layer({ experimentalBackgroundSubagents: true }))
|
const background = testEffect(layer({ experimentalBackgroundSubagents: true }))
|
||||||
|
|||||||
@@ -5,10 +5,31 @@ import { Api } from "../api"
|
|||||||
import { response } from "../groups/location"
|
import { response } from "../groups/location"
|
||||||
|
|
||||||
export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
|
export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
|
||||||
Effect.succeed(
|
Effect.gen(function* () {
|
||||||
handlers
|
return handlers
|
||||||
.handle("fs.read", (ctx) => response(FileSystem.Service.use((fs) => fs.read(ctx.query))))
|
.handle("fs.read", (ctx) =>
|
||||||
.handle("fs.list", (ctx) => response(FileSystem.Service.use((fs) => fs.list(ctx.query))))
|
response(
|
||||||
.handle("fs.find", (ctx) => response(FileSystem.Service.use((fs) => fs.find(ctx.query)))),
|
Effect.gen(function* () {
|
||||||
),
|
const fs = yield* FileSystem.Service
|
||||||
|
return yield* fs.read(ctx.query)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.handle("fs.list", (ctx) =>
|
||||||
|
response(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FileSystem.Service
|
||||||
|
return yield* fs.list(ctx.query)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.handle("fs.find", (ctx) =>
|
||||||
|
response(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FileSystem.Service
|
||||||
|
return yield* fs.find(ctx.query)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ export function Autocomplete(props: {
|
|||||||
// Get files from SDK
|
// Get files from SDK
|
||||||
const result = await sdk.client.v2.fs.find({
|
const result = await sdk.client.v2.fs.find({
|
||||||
query: baseQuery,
|
query: baseQuery,
|
||||||
|
limit: "20",
|
||||||
location: { workspace: project.workspace.current() },
|
location: { workspace: project.workspace.current() },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
diff --git a/src/download.ts b/src/download.ts
|
||||||
|
index 3454256..6dca25a 100644
|
||||||
|
--- a/src/download.ts
|
||||||
|
+++ b/src/download.ts
|
||||||
|
@@ -7,7 +7,7 @@
|
||||||
|
*/
|
||||||
|
|
||||||
|
+declare const FFF_LIBC: "gnu" | "musl";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
-import { createRequire } from "node:module";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { getLibFilename, getNpmPackageName } from "./platform";
|
||||||
|
@@ -54,14 +54,10 @@ export function binaryExists(): boolean {
|
||||||
|
* in the same directory.
|
||||||
|
*/
|
||||||
|
function resolveFromNpmPackage(): string | null {
|
||||||
|
- const packageName = getNpmPackageName();
|
||||||
|
-
|
||||||
|
try {
|
||||||
|
- // Use createRequire to resolve the platform package's location
|
||||||
|
- const require = createRequire(join(getPackageDir(), "package.json"));
|
||||||
|
- const packageJsonPath = require.resolve(`${packageName}/package.json`);
|
||||||
|
- const packageDir = dirname(packageJsonPath);
|
||||||
|
- const binaryPath = join(packageDir, getLibFilename());
|
||||||
|
+ const binaryPath = require(
|
||||||
|
+ `@ff-labs/fff-bin-${process.platform === "linux" ? `linux-${process.arch}-${typeof FFF_LIBC === "string" ? FFF_LIBC : getNpmPackageName().endsWith("musl") ? "musl" : "gnu"}` : `${process.platform}-${process.arch}`}/${process.platform === "win32" ? "fff_c.dll" : process.platform === "darwin" ? "libfff_c.dylib" : "libfff_c.so"}`,
|
||||||
|
+ );
|
||||||
|
|
||||||
|
if (existsSync(binaryPath)) {
|
||||||
|
return binaryPath;
|
||||||
Reference in New Issue
Block a user