From 2fbab71dbe8a414dbcffc70cdbd8ee0ef122a7eb Mon Sep 17 00:00:00 2001 From: Lucas Burmeister Date: Wed, 15 Jul 2026 03:49:31 +0200 Subject: [PATCH] fix: resolve relative paths from virtual cwd --- README.md | 27 ++++-- src/helpers/volume.ts | 30 +++++-- src/index.ts | 75 +++++++++------- src/operations/access.ts | 5 +- src/operations/chmod.ts | 5 +- src/operations/chown.ts | 5 +- src/operations/copy-file.ts | 5 +- src/operations/copy.ts | 5 +- src/operations/link.ts | 9 +- src/operations/make-directory.ts | 5 +- src/operations/make-temp-directory.ts | 10 ++- src/operations/make-temp-file.ts | 16 ++-- src/operations/open.ts | 11 ++- src/operations/read-directory.ts | 5 +- src/operations/read-file.ts | 9 +- src/operations/read-link.ts | 5 +- src/operations/real-path.ts | 5 +- src/operations/remove.ts | 5 +- src/operations/rename.ts | 9 +- src/operations/stat.ts | 7 +- src/operations/symlink.ts | 5 +- src/operations/truncate.ts | 9 +- src/operations/utimes.ts | 5 +- src/operations/watch.ts | 11 ++- src/operations/write-file.ts | 5 +- test/relative-paths.test.ts | 121 ++++++++++++++++++++++++++ 26 files changed, 312 insertions(+), 97 deletions(-) create mode 100644 test/relative-paths.test.ts diff --git a/README.md b/README.md index 7fe81d3..cc7bdd0 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,20 @@ Effect.runPromise( ) ``` +Relative paths use `/` as their working directory by default, so `src/index.ts` +and `/src/index.ts` address the same file. Pass a custom `cwd` to mirror a +specific Node working directory instead; the initial file tree is mounted at +that same location: + +```ts +const testLayer = layer( + { + src: { "index.ts": "export const x = 1" }, + }, + { cwd: process.cwd() }, +) +``` + Snapshot a real directory tree into a fresh in-memory layer. Files, empty directories, symlinks, file mode, owner, and access/modification times are all replicated. The caller provides the `FileSystem.FileSystem` service used @@ -72,10 +86,7 @@ import { layerFromPath } from "@lucas-bur/effect-memfs" const program = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem return yield* fs.readFileString("/data/fixtures/test.json") -}).pipe( - Effect.provide(layerFromPath("/data/fixtures")), - Effect.provide(NodeFileSystem.layer), -) +}).pipe(Effect.provide(layerFromPath("/data/fixtures")), Effect.provide(NodeFileSystem.layer)) ``` The source path is mounted at the same location in the in-memory @@ -84,10 +95,10 @@ filesystem by default; pass `{ mountAt: "/some/other/path" }` to remap. ## Roadmap - [ ] **1:1 parity with Node's filesystem** — `@lucas-bur/effect-memfs` must be a - drop-in replacement for `NodeFileSystem`: identical behavior, error tags, and - edge cases. Enforced by `test/file-system-suite.test.ts`, which runs the same - suite against both backends. Any divergence is a bug to fix, not a quirk to - document. Pinned via the `TODO(1:1-parity)` marker in `src/index.ts`. + drop-in replacement for `NodeFileSystem`: identical behavior, error tags, and + edge cases. Enforced by `test/file-system-suite.test.ts`, which runs the same + suite against both backends. Any divergence is a bug to fix, not a quirk to + document. Pinned via the `TODO(1:1-parity)` marker in `src/index.ts`. ## License diff --git a/src/helpers/volume.ts b/src/helpers/volume.ts index 46cc3be..cc67ddc 100644 --- a/src/helpers/volume.ts +++ b/src/helpers/volume.ts @@ -7,8 +7,14 @@ export interface FileTree { [key: string]: string | null | FileTree } -export const makeVol = (initialFiles: FileTree = {}): Volume => - Volume.fromNestedJSON(initialFiles, "/") +export interface VolumeOptions { + readonly cwd?: string +} + +export type ResolvePath = (path: string) => string + +export const makeVol = (initialFiles: FileTree = {}, options: VolumeOptions = {}): Volume => + Volume.fromNestedJSON(initialFiles, normalizeCwd(options.cwd ?? "/")) const copyMode = (vol: Volume, dst: string, info: FileSystem.File.Info, isLink: boolean): void => { const mode = info.mode & 0o777 @@ -48,6 +54,21 @@ const copyTimestamps = ( const toUnix = (p: string): string => p.replace(/\\/g, "/") +const normalizeCwd = (cwd: string): string => { + const normalized = toUnix(cwd) + if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) return normalized + return "/" + normalized +} + +export const makeResolvePath = (cwd = "/"): ResolvePath => { + const base = normalizeCwd(cwd) + return (path) => { + const normalized = toUnix(path) + if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) return normalized + return base.endsWith("/") ? base + normalized : base + "/" + normalized + } +} + // memfs resolves relative paths against `process.cwd()`, which would land // the in-memory tree under the OS working directory. Force a leading `/` // so a bare relative path (e.g. `mountAt: "app"`) is treated as absolute @@ -56,10 +77,7 @@ const toUnix = (p: string): string => p.replace(/\\/g, "/") // because the extra `/` would corrupt the drive-letter form on Windows // (`path.resolve("/C:/foo")` becomes `E:\C:\foo`). const toVolumePath = (p: string): string => { - const normalized = toUnix(p) - if (normalized.startsWith("/")) return normalized - if (/^[A-Za-z]:/.test(normalized)) return normalized - return "/" + normalized + return normalizeCwd(p) } const populate = ( diff --git a/src/index.ts b/src/index.ts index 067e5ef..1e4ebb1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,13 @@ import { Effect, FileSystem, Layer, type PlatformError } from "effect" import type { Volume } from "memfs" -import { makeVol, makeVolFromFileSystem, type FileTree } from "./helpers/volume.js" +import { + makeResolvePath, + makeVol, + makeVolFromFileSystem, + type FileTree, + type VolumeOptions, +} from "./helpers/volume.js" import { access } from "./operations/access.js" import { chmod } from "./operations/chmod.js" import { chown } from "./operations/chown.js" @@ -25,6 +31,7 @@ import { utimes } from "./operations/utimes.js" import { watch } from "./operations/watch.js" import { writeFile } from "./operations/write-file.js" +export type LayerOptions = VolumeOptions export { type FileTree } // TODO(1:1-parity): memfs must be 1:1 interchangeable with NodeFileSystem — same @@ -34,36 +41,44 @@ export { type FileTree } // regression test that pins the corrected behavior. Tracked at the project level // because every operation in this module depends on it. -const makeFileSystem = (vol: Volume): FileSystem.FileSystem => - FileSystem.make({ - access: access(vol), - chmod: chmod(vol), - chown: chown(vol), - copy: copy(vol), - copyFile: copyFile(vol), - link: link(vol), - makeDirectory: makeDirectory(vol), - makeTempDirectory: makeTempDirectory(vol), - makeTempDirectoryScoped: makeTempDirectoryScoped(vol), - makeTempFile: makeTempFile(vol), - makeTempFileScoped: makeTempFileScoped(vol), - open: open(vol), - readDirectory: readDirectory(vol), - readFile: readFile(vol), - readLink: readLink(vol), - realPath: realPath(vol), - remove: remove(vol), - rename: rename(vol), - stat: stat(vol), - symlink: symlink(vol), - truncate: truncate(vol), - utimes: utimes(vol), - watch: watch(vol), - writeFile: writeFile(vol), +const makeFileSystem = (vol: Volume, cwd = "/"): FileSystem.FileSystem => { + const resolvePath = makeResolvePath(cwd) + return FileSystem.make({ + access: access(vol, resolvePath), + chmod: chmod(vol, resolvePath), + chown: chown(vol, resolvePath), + copy: copy(vol, resolvePath), + copyFile: copyFile(vol, resolvePath), + link: link(vol, resolvePath), + makeDirectory: makeDirectory(vol, resolvePath), + makeTempDirectory: makeTempDirectory(vol, resolvePath), + makeTempDirectoryScoped: makeTempDirectoryScoped(vol, resolvePath), + makeTempFile: makeTempFile(vol, resolvePath), + makeTempFileScoped: makeTempFileScoped(vol, resolvePath), + open: open(vol, resolvePath), + readDirectory: readDirectory(vol, resolvePath), + readFile: readFile(vol, resolvePath), + readLink: readLink(vol, resolvePath), + realPath: realPath(vol, resolvePath), + remove: remove(vol, resolvePath), + rename: rename(vol, resolvePath), + stat: stat(vol, resolvePath), + symlink: symlink(vol, resolvePath), + truncate: truncate(vol, resolvePath), + utimes: utimes(vol, resolvePath), + watch: watch(vol, resolvePath), + writeFile: writeFile(vol, resolvePath), }) +} -export const layer = (initialFiles: FileTree = {}): Layer.Layer => - Layer.sync(FileSystem.FileSystem, () => makeFileSystem(makeVol(initialFiles))) +export const layer = ( + initialFiles: FileTree = {}, + options: LayerOptions = {}, +): Layer.Layer => + Layer.sync(FileSystem.FileSystem, () => { + const cwd = options.cwd ?? "/" + return makeFileSystem(makeVol(initialFiles, { cwd }), cwd) + }) /** * Build a `FileSystem` layer pre-populated from a real directory. @@ -77,7 +92,7 @@ export const layer = (initialFiles: FileTree = {}): Layer.Layer + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["access"] => (path, options) => - fromPromise(() => vol.promises.access(path, accessMode(options)), "access", path) + fromPromise(() => vol.promises.access(resolvePath(path), accessMode(options)), "access", path) diff --git a/src/operations/chmod.ts b/src/operations/chmod.ts index eeca858..c6315c1 100644 --- a/src/operations/chmod.ts +++ b/src/operations/chmod.ts @@ -2,8 +2,9 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" export const chmod = - (vol: Volume): FileSystem.FileSystem["chmod"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["chmod"] => (path, mode) => - fromPromise(() => vol.promises.chmod(path, mode), "chmod", path) + fromPromise(() => vol.promises.chmod(resolvePath(path), mode), "chmod", path) diff --git a/src/operations/chown.ts b/src/operations/chown.ts index 33a5cdc..42b3cfa 100644 --- a/src/operations/chown.ts +++ b/src/operations/chown.ts @@ -2,8 +2,9 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" export const chown = - (vol: Volume): FileSystem.FileSystem["chown"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["chown"] => (path, uid, gid) => - fromPromise(() => vol.promises.chown(path, uid, gid), "chown", path) + fromPromise(() => vol.promises.chown(resolvePath(path), uid, gid), "chown", path) diff --git a/src/operations/copy-file.ts b/src/operations/copy-file.ts index d7247bc..e208e8e 100644 --- a/src/operations/copy-file.ts +++ b/src/operations/copy-file.ts @@ -2,8 +2,9 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" export const copyFile = - (vol: Volume): FileSystem.FileSystem["copyFile"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["copyFile"] => (from, to) => - fromPromise(() => vol.promises.copyFile(from, to), "copyFile", from) + fromPromise(() => vol.promises.copyFile(resolvePath(from), resolvePath(to)), "copyFile", from) diff --git a/src/operations/copy.ts b/src/operations/copy.ts index 5d9dd44..edf5a99 100644 --- a/src/operations/copy.ts +++ b/src/operations/copy.ts @@ -2,13 +2,14 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" export const copy = - (vol: Volume): FileSystem.FileSystem["copy"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["copy"] => (from, to, options) => fromPromise( () => - vol.promises.cp(from, to, { + vol.promises.cp(resolvePath(from), resolvePath(to), { recursive: true, force: options?.overwrite ?? false, errorOnExist: options?.overwrite !== true, diff --git a/src/operations/link.ts b/src/operations/link.ts index 5ac93c4..d73fc19 100644 --- a/src/operations/link.ts +++ b/src/operations/link.ts @@ -2,8 +2,13 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" export const link = - (vol: Volume): FileSystem.FileSystem["link"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["link"] => (existingPath, newPath) => - fromPromise(() => vol.promises.link(existingPath, newPath), "link", existingPath) + fromPromise( + () => vol.promises.link(resolvePath(existingPath), resolvePath(newPath)), + "link", + existingPath, + ) diff --git a/src/operations/make-directory.ts b/src/operations/make-directory.ts index 77dc622..7bcb3e6 100644 --- a/src/operations/make-directory.ts +++ b/src/operations/make-directory.ts @@ -2,13 +2,14 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" export const makeDirectory = - (vol: Volume): FileSystem.FileSystem["makeDirectory"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["makeDirectory"] => (path, options) => fromPromise( () => - vol.promises.mkdir(path, { + vol.promises.mkdir(resolvePath(path), { recursive: options?.recursive ?? false, mode: options?.mode, }), diff --git a/src/operations/make-temp-directory.ts b/src/operations/make-temp-directory.ts index 7fc1e81..54540d9 100644 --- a/src/operations/make-temp-directory.ts +++ b/src/operations/make-temp-directory.ts @@ -2,14 +2,15 @@ import { Effect, type FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" import { removeFactory } from "./remove.js" export const makeTempDirectoryFactory = (method: string) => - (vol: Volume): FileSystem.FileSystem["makeTempDirectory"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["makeTempDirectory"] => (options) => fromPromise(async () => { - const base = typeof options?.directory === "string" ? options.directory : "/tmp" + const base = resolvePath(typeof options?.directory === "string" ? options.directory : "/tmp") await vol.promises.mkdir(base, { recursive: true }) return vol.promises.mkdtemp(base + "/" + (options?.prefix ?? "")) as Promise }, method) @@ -18,9 +19,10 @@ export const makeTempDirectory = makeTempDirectoryFactory("makeTempDirectory") export const makeTempDirectoryScoped = ( vol: Volume, + resolvePath: ResolvePath, ): FileSystem.FileSystem["makeTempDirectoryScoped"] => { - const makeDir = makeTempDirectoryFactory("makeTempDirectoryScoped")(vol) - const rmDir = removeFactory("makeTempDirectoryScoped")(vol) + const makeDir = makeTempDirectoryFactory("makeTempDirectoryScoped")(vol, resolvePath) + const rmDir = removeFactory("makeTempDirectoryScoped")(vol, resolvePath) return (options) => Effect.acquireRelease(makeDir(options), (dir) => Effect.orDie(rmDir(dir, { recursive: true }))) } diff --git a/src/operations/make-temp-file.ts b/src/operations/make-temp-file.ts index b001aa3..008f30d 100644 --- a/src/operations/make-temp-file.ts +++ b/src/operations/make-temp-file.ts @@ -1,15 +1,16 @@ import { Effect, type FileSystem } from "effect" import type { Volume } from "memfs" +import type { ResolvePath } from "../helpers/volume.js" import { makeTempDirectoryFactory } from "./make-temp-directory.js" import { removeFactory } from "./remove.js" import { writeFile } from "./write-file.js" const makeTempFileFactory = (method: string) => - (vol: Volume): FileSystem.FileSystem["makeTempFile"] => { - const makeDir = makeTempDirectoryFactory(method)(vol) - const write = writeFile(vol) + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["makeTempFile"] => { + const makeDir = makeTempDirectoryFactory(method)(vol, resolvePath) + const write = writeFile(vol, resolvePath) return Effect.fnUntraced(function* (options) { const dir = yield* makeDir(options) const random = Math.random().toString(16).slice(2, 14) @@ -21,9 +22,12 @@ const makeTempFileFactory = export const makeTempFile = makeTempFileFactory("makeTempFile") -export const makeTempFileScoped = (vol: Volume): FileSystem.FileSystem["makeTempFileScoped"] => { - const makeFile = makeTempFileFactory("makeTempFileScoped")(vol) - const rmDir = removeFactory("makeTempFileScoped")(vol) +export const makeTempFileScoped = ( + vol: Volume, + resolvePath: ResolvePath, +): FileSystem.FileSystem["makeTempFileScoped"] => { + const makeFile = makeTempFileFactory("makeTempFileScoped")(vol, resolvePath) + const rmDir = removeFactory("makeTempFileScoped")(vol, resolvePath) return (options) => Effect.acquireRelease(makeFile(options), (file) => Effect.orDie(rmDir(file.slice(0, file.lastIndexOf("/")), { recursive: true })), diff --git a/src/operations/open.ts b/src/operations/open.ts index b398143..4ed3b70 100644 --- a/src/operations/open.ts +++ b/src/operations/open.ts @@ -3,16 +3,21 @@ import { pipe } from "effect/Function" import type { Volume } from "memfs" import { fromSync } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" import { makeFile } from "./make-file.js" export const openFactory = (method: string) => - (vol: Volume): FileSystem.FileSystem["open"] => { + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["open"] => { const createFile = makeFile(vol) return (path, options) => pipe( Effect.acquireRelease( - fromSync(() => vol.openSync(path, options?.flag ?? "r", options?.mode), method, path), + fromSync( + () => vol.openSync(resolvePath(path), options?.flag ?? "r", options?.mode), + method, + path, + ), (rawFd) => Effect.orDie(fromSync(() => vol.closeSync(rawFd), method, path)), ), Effect.map((rawFd) => @@ -25,4 +30,4 @@ export const openFactory = ) } -export const open = (vol: Volume) => openFactory("open")(vol) +export const open = (vol: Volume, resolvePath: ResolvePath) => openFactory("open")(vol, resolvePath) diff --git a/src/operations/read-directory.ts b/src/operations/read-directory.ts index df00be3..a58b889 100644 --- a/src/operations/read-directory.ts +++ b/src/operations/read-directory.ts @@ -2,6 +2,7 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" // Match the shape of `node:fs/promises.readdir(path, { recursive: true })`: // paths are returned relative to the input, separated by forward slashes, and @@ -12,11 +13,11 @@ const stripTrailingSlash = (p: string): string => p === "/" ? p : p.endsWith("/") ? p.slice(0, -1) : p export const readDirectory = - (vol: Volume): FileSystem.FileSystem["readDirectory"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["readDirectory"] => (path, options) => fromPromise( () => { - const dir = vol.promises.readdir(stripTrailingSlash(path), { + const dir = vol.promises.readdir(stripTrailingSlash(resolvePath(path)), { recursive: options?.recursive ?? false, }) as Promise return dir.then((entries) => entries.map((e) => (e.startsWith("/") ? e.slice(1) : e))) diff --git a/src/operations/read-file.ts b/src/operations/read-file.ts index 4e5438a..26597b8 100644 --- a/src/operations/read-file.ts +++ b/src/operations/read-file.ts @@ -2,6 +2,7 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" // NOTE: Unlike NodeFileSystem, memfs has no AbortSignal support, // so we skip the `signal` option that the reference passes to fs.readFile. @@ -9,6 +10,10 @@ import { fromPromise } from "../helpers/promise.js" // FileSystem.make() reads the result via TextDecoder, so the cast should be safe. export const readFile = - (vol: Volume): FileSystem.FileSystem["readFile"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["readFile"] => (path) => - fromPromise(() => vol.promises.readFile(path) as Promise, "readFile", path) + fromPromise( + () => vol.promises.readFile(resolvePath(path)) as Promise, + "readFile", + path, + ) diff --git a/src/operations/read-link.ts b/src/operations/read-link.ts index e50af05..1dac914 100644 --- a/src/operations/read-link.ts +++ b/src/operations/read-link.ts @@ -2,10 +2,11 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" // memfs returns string by default for readlink (encoding = 'utf8'). export const readLink = - (vol: Volume): FileSystem.FileSystem["readLink"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["readLink"] => (path) => - fromPromise(() => vol.promises.readlink(path) as Promise, "readlink", path) + fromPromise(() => vol.promises.readlink(resolvePath(path)) as Promise, "readlink", path) diff --git a/src/operations/real-path.ts b/src/operations/real-path.ts index de46a42..aa2da33 100644 --- a/src/operations/real-path.ts +++ b/src/operations/real-path.ts @@ -2,10 +2,11 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" // memfs returns string by default for realpath (encoding = 'utf8'). export const realPath = - (vol: Volume): FileSystem.FileSystem["realPath"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["realPath"] => (path) => - fromPromise(() => vol.promises.realpath(path) as Promise, "realpath", path) + fromPromise(() => vol.promises.realpath(resolvePath(path)) as Promise, "realpath", path) diff --git a/src/operations/remove.ts b/src/operations/remove.ts index 253167c..afbdd8d 100644 --- a/src/operations/remove.ts +++ b/src/operations/remove.ts @@ -2,14 +2,15 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" export const removeFactory = (method: string) => - (vol: Volume): FileSystem.FileSystem["remove"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["remove"] => (path, options) => fromPromise( () => - vol.promises.rm(path, { + vol.promises.rm(resolvePath(path), { recursive: options?.recursive ?? false, force: options?.force ?? false, }), diff --git a/src/operations/rename.ts b/src/operations/rename.ts index 0f545f2..67be3d7 100644 --- a/src/operations/rename.ts +++ b/src/operations/rename.ts @@ -2,8 +2,13 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" export const rename = - (vol: Volume): FileSystem.FileSystem["rename"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["rename"] => (oldPath, newPath) => - fromPromise(() => vol.promises.rename(oldPath, newPath), "rename", oldPath) + fromPromise( + () => vol.promises.rename(resolvePath(oldPath), resolvePath(newPath)), + "rename", + oldPath, + ) diff --git a/src/operations/stat.ts b/src/operations/stat.ts index 69c4603..bad00c4 100644 --- a/src/operations/stat.ts +++ b/src/operations/stat.ts @@ -2,6 +2,7 @@ import { Effect, FileSystem, Option } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" type MemfsStats = Awaited> @@ -39,6 +40,8 @@ const makeInfo = (stat: MemfsStats): FileSystem.File.Info => ({ export { makeInfo } export const stat = - (vol: Volume): FileSystem.FileSystem["stat"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["stat"] => (path) => - fromPromise(() => vol.promises.stat(path), "stat", path).pipe(Effect.map((s) => makeInfo(s))) + fromPromise(() => vol.promises.stat(resolvePath(path)), "stat", path).pipe( + Effect.map((s) => makeInfo(s)), + ) diff --git a/src/operations/symlink.ts b/src/operations/symlink.ts index a70a30e..d3af347 100644 --- a/src/operations/symlink.ts +++ b/src/operations/symlink.ts @@ -2,8 +2,9 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" export const symlink = - (vol: Volume): FileSystem.FileSystem["symlink"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["symlink"] => (target, path) => - fromPromise(() => vol.promises.symlink(target, path), "symlink", path) + fromPromise(() => vol.promises.symlink(target, resolvePath(path)), "symlink", path) diff --git a/src/operations/truncate.ts b/src/operations/truncate.ts index fec584e..19f2a23 100644 --- a/src/operations/truncate.ts +++ b/src/operations/truncate.ts @@ -2,8 +2,13 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" export const truncate = - (vol: Volume): FileSystem.FileSystem["truncate"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["truncate"] => (path, length) => - fromPromise(() => vol.promises.truncate(path, Number(length ?? 0)), "truncate", path) + fromPromise( + () => vol.promises.truncate(resolvePath(path), Number(length ?? 0)), + "truncate", + path, + ) diff --git a/src/operations/utimes.ts b/src/operations/utimes.ts index 1db4fce..c813be9 100644 --- a/src/operations/utimes.ts +++ b/src/operations/utimes.ts @@ -2,8 +2,9 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" export const utimes = - (vol: Volume): FileSystem.FileSystem["utimes"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["utimes"] => (path, atime, mtime) => - fromPromise(() => vol.promises.utimes(path, atime, mtime), "utimes", path) + fromPromise(() => vol.promises.utimes(resolvePath(path), atime, mtime), "utimes", path) diff --git a/src/operations/watch.ts b/src/operations/watch.ts index 968837a..9463bfa 100644 --- a/src/operations/watch.ts +++ b/src/operations/watch.ts @@ -2,6 +2,7 @@ import { Cause, Effect, type FileSystem, PlatformError, Queue, Stream } from "ef import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" // QUIRK (?): memfs fires the watcher callback twice per writeFileSync // (two "change" events for a single write). This is a bug in the @@ -60,9 +61,11 @@ const watchNode = (vol: Volume) => (path: string) => ) export const watch = - (vol: Volume): FileSystem.FileSystem["watch"] => - (path) => - fromPromise(() => vol.promises.stat(path), "stat", path).pipe( - Effect.map(() => watchNode(vol)(path)), + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["watch"] => + (path) => { + const resolvedPath = resolvePath(path) + return fromPromise(() => vol.promises.stat(resolvedPath), "stat", path).pipe( + Effect.map(() => watchNode(vol)(resolvedPath)), Stream.unwrap, ) + } diff --git a/src/operations/write-file.ts b/src/operations/write-file.ts index ccf4576..3721a68 100644 --- a/src/operations/write-file.ts +++ b/src/operations/write-file.ts @@ -2,16 +2,17 @@ import type { FileSystem } from "effect" import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" +import type { ResolvePath } from "../helpers/volume.js" // NOTE: Unlike NodeFileSystem, memfs has no AbortSignal support, // so we skip the `signal` option that the reference passes to fs.writeFile. export const writeFile = - (vol: Volume): FileSystem.FileSystem["writeFile"] => + (vol: Volume, resolvePath: ResolvePath): FileSystem.FileSystem["writeFile"] => (path, data, options) => fromPromise( () => - vol.promises.writeFile(path, data, { + vol.promises.writeFile(resolvePath(path), data, { ...(options?.flag !== undefined && { flag: options.flag }), ...(options?.mode !== undefined && { mode: options.mode }), }), diff --git a/test/relative-paths.test.ts b/test/relative-paths.test.ts new file mode 100644 index 0000000..f1c666a --- /dev/null +++ b/test/relative-paths.test.ts @@ -0,0 +1,121 @@ +import { expect, it } from "@effect/vitest" +import { Effect, FileSystem } from "effect" + +import { layer } from "../src/index.js" + +const TestLayer = layer({ + project: { + "config.json": "configured", + }, + "rename-source.txt": "rename", + "copy-source.txt": "copy", + "link-source.txt": "link", + links: { + "target.txt": "target", + }, +}) + +it.effect("reads initialized files through relative and absolute paths", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + + expect(yield* fs.readFileString("project/config.json")).toBe("configured") + expect(yield* fs.readFileString("/project/config.json")).toBe("configured") + expect(yield* fs.readDirectory(".")).toContain("project") + }).pipe(Effect.provide(TestLayer)), +) + +it.effect("writes and opens files relative to the virtual root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + + yield* fs.writeFileString("written.txt", "written") + expect(yield* fs.readFileString("/written.txt")).toBe("written") + + const file = yield* fs.open("opened.txt", { flag: "w+" }) + yield* file.writeAll(new TextEncoder().encode("opened")) + expect(yield* fs.readFileString("/opened.txt")).toBe("opened") + }).pipe(Effect.provide(TestLayer)), +) + +it.effect("resolves both paths for rename, copy, and link", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + + yield* fs.rename("rename-source.txt", "renamed.txt") + yield* fs.copy("copy-source.txt", "copied.txt") + yield* fs.link("link-source.txt", "linked.txt") + + expect(yield* fs.readFileString("/renamed.txt")).toBe("rename") + expect(yield* fs.readFileString("/copied.txt")).toBe("copy") + expect(yield* fs.readFileString("/linked.txt")).toBe("link") + }).pipe(Effect.provide(TestLayer)), +) + +it.effect("keeps relative symlink targets relative to the link directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + + yield* fs.symlink("target.txt", "links/link.txt") + + expect(yield* fs.readLink("/links/link.txt")).toBe("target.txt") + expect(yield* fs.readFileString("links/link.txt")).toBe("target") + }).pipe(Effect.provide(TestLayer)), +) + +it.effect("creates temporary entries in relative directories", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + + yield* fs.makeDirectory("temp") + const directory = yield* fs.makeTempDirectory({ directory: "temp", prefix: "dir-" }) + const file = yield* fs.makeTempFile({ directory: "temp", prefix: "file-" }) + + expect((yield* fs.stat(directory)).type).toBe("Directory") + expect((yield* fs.stat(file)).type).toBe("File") + expect(directory.startsWith("/temp/dir-")).toBe(true) + expect(file.startsWith("/temp/file-")).toBe(true) + }).pipe(Effect.provide(TestLayer)), +) + +it.effect("supports a configurable working directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + + expect(yield* fs.readFileString("project/config.json")).toBe("configured") + expect(yield* fs.readFileString("/workspace/project/config.json")).toBe("configured") + expect(yield* fs.exists("/project/config.json")).toBe(false) + }).pipe( + Effect.provide( + layer( + { + project: { + "config.json": "configured", + }, + }, + { cwd: "/workspace" }, + ), + ), + ), +) + +it.effect("can mirror the Node process working directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const absolute = process.cwd().replace(/\\/g, "/") + "/project/config.json" + + expect(yield* fs.readFileString("project/config.json")).toBe("configured") + expect(yield* fs.readFileString(absolute)).toBe("configured") + }).pipe( + Effect.provide( + layer( + { + project: { + "config.json": "configured", + }, + }, + { cwd: process.cwd() }, + ), + ), + ), +)