Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
30 changes: 24 additions & 6 deletions src/helpers/volume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 = (
Expand Down
75 changes: 45 additions & 30 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand All @@ -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<FileSystem.FileSystem> =>
Layer.sync(FileSystem.FileSystem, () => makeFileSystem(makeVol(initialFiles)))
export const layer = (
initialFiles: FileTree = {},
options: LayerOptions = {},
): Layer.Layer<FileSystem.FileSystem> =>
Layer.sync(FileSystem.FileSystem, () => {
const cwd = options.cwd ?? "/"
return makeFileSystem(makeVol(initialFiles, { cwd }), cwd)
})

/**
* Build a `FileSystem` layer pre-populated from a real directory.
Expand All @@ -77,7 +92,7 @@ export const layer = (initialFiles: FileTree = {}): Layer.Layer<FileSystem.FileS
* remap it.
*
* @example
* ```ts
* ;```ts
* import { NodeFileSystem } from "@effect/platform-node-shared"
* import { Effect, FileSystem } from "effect"
* import { layerFromPath } from "@lucas-bur/effect-memfs"
Expand Down
5 changes: 3 additions & 2 deletions src/operations/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Volume } from "memfs"
import { fs } from "memfs"

import { fromPromise } from "../helpers/promise.js"
import type { ResolvePath } from "../helpers/volume.js"

const { F_OK, R_OK, W_OK } = fs.constants

Expand All @@ -17,6 +18,6 @@ const accessMode = (
}

export const access =
(vol: Volume): FileSystem.FileSystem["access"] =>
(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)
5 changes: 3 additions & 2 deletions src/operations/chmod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 3 additions & 2 deletions src/operations/chown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 3 additions & 2 deletions src/operations/copy-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 3 additions & 2 deletions src/operations/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions src/operations/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
5 changes: 3 additions & 2 deletions src/operations/make-directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand Down
10 changes: 6 additions & 4 deletions src/operations/make-temp-directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
}, method)
Expand All @@ -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 })))
}
16 changes: 10 additions & 6 deletions src/operations/make-temp-file.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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 })),
Expand Down
Loading
Loading