refactor(core): share FileSystem entry operations across backends
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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}`))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user