refactor(core): share FileSystem entry operations across backends

This commit is contained in:
Kit Langton
2026-08-06 17:03:56 -04:00
parent 4729a3f9ae
commit 39dab7199f
2 changed files with 92 additions and 98 deletions
+81 -94
View File
@@ -87,13 +87,10 @@ export class InvalidPatternError extends Schema.TaggedErrorClass<InvalidPatternE
const mapSearchError = (error: unknown) =>
error instanceof SearchPathError || error instanceof SearchError ? error : new SearchError({ cause: error })
const mapGrepError = (error: unknown) => {
if (error instanceof SearchPathError) return error
if (error instanceof SearchError) return error
if (error instanceof Ripgrep.InvalidPatternError)
return new InvalidPatternError({ pattern: error.pattern, message: error.message })
return new SearchError({ cause: error })
}
const mapGrepError = (error: unknown) =>
error instanceof Ripgrep.InvalidPatternError
? new InvalidPatternError({ pattern: error.pattern, message: error.message })
: mapSearchError(error)
export const Event = FileSystem.Event
@@ -107,18 +104,11 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
interface SearchPath {
readonly resolve: (from: string, to: string) => string
readonly relative: (from: string, to: string) => string
readonly dirname: (value: string) => string
readonly basename: (value: string) => string
}
const makeSearch = (
location: Location.Interface,
ripgrep: Ripgrep.Interface,
paths: SearchPath,
stat: (target: SearchTarget) => Effect.Effect<WorkspaceEnvironment.FileInfo, SearchPathError | SearchError>,
paths: Pick<typeof path.posix, "resolve" | "relative" | "dirname" | "basename">,
stat: (target: SearchTarget) => Effect.Effect<{ readonly type: string }, SearchPathError | SearchError>,
) => ({
glob: Effect.fn("FileSystem.glob")(function* (input: GlobSearchInput) {
if ((yield* stat(input.target)).type !== "Directory")
@@ -171,6 +161,57 @@ const makeSearch = (
}, Effect.mapError(mapGrepError)),
})
/**
* Entry primitives the shared read/list implementations consume. Escape
* failures and type mismatches are defects, matching route behavior.
*/
interface EntryBackend {
readonly paths: Pick<typeof path.posix, "resolve" | "relative" | "join" | "sep">
readonly contains: (parent: string, child: string) => boolean
readonly realPath: (target: string) => Effect.Effect<string>
/** Reads one file; non-file targets die. */
readonly readFile: (real: string) => Effect.Effect<Uint8Array>
/** Lists one directory; non-directory targets die. */
readonly listDir: (real: string) => Effect.Effect<readonly FSUtil.DirEntry[]>
}
const makeEntries = (location: Location.Interface, root: string, backend: EntryBackend) => {
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = backend.paths.resolve(location.directory, input ?? ".")
if (!backend.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* backend.realPath(absolute)
if (!backend.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real }
})
return {
read: Effect.fn("FileSystem.read")(function* (input: ReadInput) {
const target = yield* resolve(input.path)
return {
content: yield* backend.readFile(target.real),
mime: FSUtil.mimeType(target.real),
}
}),
list: Effect.fn("FileSystem.list")(function* (input: ListInput = {}) {
const target = yield* resolve(input.path)
const items = yield* backend.listDir(target.real)
return items
.flatMap((item) => {
if (item.type !== "file" && item.type !== "directory") return []
const absolute = backend.paths.join(target.absolute, item.name)
const relative = backend.paths.relative(location.directory, absolute)
return [
Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? backend.paths.sep : "")),
type: item.type,
}),
]
})
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1))
}),
}
}
const baseLayer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -187,50 +228,24 @@ const baseLayer = Layer.effect(
),
)
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = path.resolve(location.directory, input ?? ".")
if (!FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, directory: location.directory, root }
})
return Service.of({
find: search.find,
...searches,
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
return {
content: yield* fs.readFile(target.real).pipe(Effect.orDie),
mime: FSUtil.mimeType(target.real),
}
}),
list: Effect.fn("FileSystem.list")(function* (input = {}) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(target.real).pipe(
Effect.orDie,
Effect.map((items) =>
items
.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 [
Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
type: item.type,
}),
]
})
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
}),
const entries = makeEntries(location, root, {
paths: path,
contains: FSUtil.contains,
realPath: (target) => fs.realPath(target).pipe(Effect.orDie),
readFile: (real) =>
Effect.gen(function* () {
const info = yield* fs.stat(real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
return yield* fs.readFile(real).pipe(Effect.orDie)
}),
listDir: (real) =>
Effect.gen(function* () {
const info = yield* fs.stat(real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(real).pipe(Effect.orDie)
}),
})
return Service.of({ find: search.find, ...searches, ...entries })
}),
)
@@ -258,45 +273,17 @@ const hostedLayer = Layer.effect(
),
)
const root = yield* env.files.realPath(location.directory).pipe(Effect.orDie)
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = path.posix.resolve(location.directory, input ?? ".")
if (!FSUtil.containsPosix(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* env.files.realPath(absolute).pipe(Effect.orDie)
if (!FSUtil.containsPosix(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, directory: location.directory, root }
const entries = makeEntries(location, root, {
paths: path.posix,
contains: FSUtil.containsPosix,
realPath: (target) => env.files.realPath(target).pipe(Effect.orDie),
readFile: (real) => env.files.read(real).pipe(Effect.orDie),
listDir: (real) => env.files.list(real).pipe(Effect.orDie),
})
return Service.of({
find: () => Effect.logWarning("find is not supported for hosted locations yet").pipe(Effect.as([])),
...searches,
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolve(input.path)
return {
content: yield* env.files.read(target.real).pipe(Effect.orDie),
mime: FSUtil.mimeType(target.real),
}
}),
list: Effect.fn("FileSystem.list")(function* (input = {}) {
const target = yield* resolve(input.path)
return yield* env.files.list(target.real).pipe(
Effect.orDie,
Effect.map((items) =>
items
.flatMap((item) => {
if (item.type !== "file" && item.type !== "directory") return []
const absolute = path.posix.join(target.absolute, item.name)
const relative = path.posix.relative(target.directory, absolute)
return [
Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? "/" : "")),
type: item.type,
}),
]
})
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
}),
...entries,
})
}),
)
+11 -4
View File
@@ -270,13 +270,20 @@ export namespace FSUtil {
}
export function contains(parent: string, child: string) {
const result = relative(parent, child)
return result === "" || (!isAbsolute(result) && result !== ".." && !result.startsWith(`..${sep}`))
return containsUsing({ relative, isAbsolute, sep }, parent, child)
}
/** `contains` with posix rules regardless of host platform, for provider paths. */
export function containsPosix(parent: string, child: string) {
const result = path.posix.relative(parent, child)
return result === "" || (!path.posix.isAbsolute(result) && result !== ".." && !result.startsWith("../"))
return containsUsing(path.posix, parent, child)
}
function containsUsing(
paths: Pick<typeof path.posix, "relative" | "isAbsolute" | "sep">,
parent: string,
child: string,
) {
const result = paths.relative(parent, child)
return result === "" || (!paths.isAbsolute(result) && result !== ".." && !result.startsWith(`..${paths.sep}`))
}
}