From 39dab7199f2d0f145b8d88ae0fec140ae6a689f9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 6 Aug 2026 17:03:56 -0400 Subject: [PATCH] refactor(core): share FileSystem entry operations across backends --- packages/core/src/filesystem.ts | 175 +++++++++++++++----------------- packages/util/src/fs-util.ts | 15 ++- 2 files changed, 92 insertions(+), 98 deletions(-) diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index e5b1aeaae1..cdff0337df 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -87,13 +87,10 @@ export class InvalidPatternError extends Schema.TaggedErrorClass 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()("@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, + paths: Pick, + 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 + readonly contains: (parent: string, child: string) => boolean + readonly realPath: (target: string) => Effect.Effect + /** Reads one file; non-file targets die. */ + readonly readFile: (real: string) => Effect.Effect + /** Lists one directory; non-directory targets die. */ + readonly listDir: (real: string) => Effect.Effect +} + +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, }) }), ) diff --git a/packages/util/src/fs-util.ts b/packages/util/src/fs-util.ts index 69f7087e40..bc3deab69e 100644 --- a/packages/util/src/fs-util.ts +++ b/packages/util/src/fs-util.ts @@ -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, + parent: string, + child: string, + ) { + const result = paths.relative(parent, child) + return result === "" || (!paths.isAbsolute(result) && result !== ".." && !result.startsWith(`..${paths.sep}`)) } }