export * as FileSystemSearch from "./search" import { makeLocationNode } from "../effect/app-node" import path from "path" import { Context, Effect, Layer, Scope } from "effect" import { Fff } from "#fff" import fuzzysort from "fuzzysort" import { FileSystem } from "../filesystem" import { FSUtil } from "../fs-util" import { Location } from "../location" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" import { Flag } from "../flag/flag" export interface Interface { readonly find: (input: FileSystem.FindInput) => Effect.Effect readonly glob: (input: FileSystem.GlobInput) => Effect.Effect readonly grep: (input: FileSystem.GrepInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/FileSystem/Search") {} export const ripgrepLayer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service const location = yield* Location.Service const ripgrep = yield* Ripgrep.Service const scope = yield* Scope.Scope const state = { files: [] as string[], directories: [] as string[], } const directories = new Set() yield* ripgrep .find({ cwd: location.directory, pattern: "*", limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000, onEntry: (entry) => Effect.sync(() => { state.files.push(entry.path) const parts = entry.path.split("/") parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep)) state.directories = Array.from(directories) }), }) .pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope)) 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) => FileSystem.Entry.make({ ...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) => FileSystem.Match.make({ ...match, entry: FileSystem.Entry.make({ ...match.entry, path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))), }), }), ), ), Effect.orDie, ) }), find: (input) => Effect.gen(function* () { 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) return FileSystem.Entry.make({ path: RelativePath.make(relative), type, }) }) }), }) }), ) export const fffLayer = Layer.effect( Service, Effect.gen(function* () { const location = yield* Location.Service const result = yield* Effect.try({ try: () => Fff.create({ basePath: location.directory, aiMode: true, }), catch: (cause) => cause, }).pipe( Effect.catch((error) => Effect.logWarning("failed to initialize fff", { error }).pipe(Effect.as(undefined))), ) if (!result?.ok) { if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error }) return Service.of({ find: () => Effect.succeed([]), glob: () => Effect.succeed([]), grep: () => Effect.succeed([]), }) } yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore)) 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) => FileSystem.Entry.make({ path: RelativePath.make(item.relativePath.replaceAll("\\", "/")), type: "file", }), ) }), 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 FileSystem.Match.make({ entry: FileSystem.Entry.make({ path: RelativePath.make(match.relativePath.replaceAll("\\", "/")), type: "file", }), 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, index) => ({ path: item.relativePath, type: "file" as const, score: found.value.scores[index]?.total ?? 0, })) } 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, index) => ({ path: item.relativePath, type: "directory" as const, score: found.value.scores[index]?.total ?? 0, })) } const found = result.value.mixedSearch(input.query.trim(), options) if (!found.ok) throw found.error return found.value.items.map((item, index) => ({ path: item.item.relativePath, type: item.type, score: found.value.scores[index]?.total ?? 0, })) })() return items .sort((a, b) => b.score - a.score || a.path.length - b.path.length) .map((item) => { const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "") return FileSystem.Entry.make({ path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")), type: item.type, }) }) }), }) }), ) const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer))) export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] })