From 2ad81084c8fe65020aad3a2234b87981ba575ddb Mon Sep 17 00:00:00 2001 From: Magnus Madsen Date: Sun, 22 Mar 2026 07:15:58 +0100 Subject: [PATCH 1/3] feat: add FileSystem page covering Fs effects, hierarchy, and middleware Co-Authored-By: Claude Opus 4.6 (1M context) --- src/SUMMARY.md | 1 + src/filesystem.md | 712 +++++++++++++++++++++++++++++++++++++++++ src/library-effects.md | 1 + 3 files changed, 714 insertions(+) create mode 100644 src/filesystem.md diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 0eee67b5..8828d827 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -39,6 +39,7 @@ - [Console](./console.md) - [Env](./env.md) - [Exit](./exit.md) + - [FileSystem](./filesystem.md) - [Http and Https](./http-and-https.md) - [Logger](./logger.md) - [Process](./process.md) diff --git a/src/filesystem.md b/src/filesystem.md new file mode 100644 index 00000000..f32c0d37 --- /dev/null +++ b/src/filesystem.md @@ -0,0 +1,712 @@ +# FileSystem + +Flix provides a family of effects for filesystem operations. The key modules +are: + +- `Fs.FileSystem` — the unified `FileSystem` effect (all 29 operations) +- `Fs.FileRead` — the `FileRead` effect (read, readLines, readBytes) +- `Fs.FileWrite` — the `FileWrite` effect (write, append, delete, copy, move, mkdir, etc.) +- `Fs.FileStat` — the `FileStat` effect (exists, type tests, permissions, timestamps, size) +- `Fs.DirList` — the `DirList` effect (listing directory contents) +- `Fs.Glob` — the `Glob` effect (finding files by pattern) +- `Fs.Size` — utilities for working with file sizes + +All effects have default handlers, so no explicit `runWithIO` call is needed in +`main`. + +## Writing a File + +The simplest way to write a file is with `FileWrite.write`. It takes a named +`str` argument and a path, and returns `Result[IoError, Unit]`: + +```flix +use Fs.FileWrite + +def main(): Unit \ { FileWrite, IO } = + match FileWrite.write(str = "Hello, Flix!", "greeting.txt") { + case Ok(_) => println("File written successfully.") + case Err(err) => println("Error: ${err}") + } +``` + +The `FileWrite` effect appears in the type signature of `main` alongside `IO`. + +## Reading a File + +Use `FileRead.read` to read an entire file as a string: + +```flix +use Fs.FileRead + +def main(): Unit \ { FileRead, IO } = + match FileRead.read("example.txt") { + case Ok(content) => println(content) + case Err(err) => println("Error: ${err}") + } +``` + +## Reading and Writing Lines + +Use `readLines` and `writeLines` to work with files line by line. The `lines` +argument is a `List[String]`: + +```flix +use Fs.FileRead +use Fs.FileWrite + +def main(): Unit \ { FileRead, FileWrite, IO } = + match FileWrite.writeLines(lines = List#{"Line 1", "Line 2", "Line 3"}, "data.txt") { + case Err(err) => println("Write error: ${err}") + case Ok(_) => + match FileRead.readLines("data.txt") { + case Ok(lines) => + foreach (line <- lines) { + println(line) + } + case Err(err) => println("Read error: ${err}") + } + } +``` + +## Reading and Writing Bytes + +Use `readBytes` and `writeBytes` for binary data. `writeBytes` takes a +`Vector[Int8]` directly, and `readBytes` returns one: + +```flix +use Fs.FileRead +use Fs.FileWrite + +def main(): Unit \ { FileRead, FileWrite, IO } = + let data = Vector#{72i8, 101i8, 108i8, 108i8, 111i8}; + match FileWrite.writeBytes(data, "binary.dat") { + case Err(err) => println("Write error: ${err}") + case Ok(_) => + match FileRead.readBytes("binary.dat") { + case Ok(bytes) => + println("Read ${Vector.length(bytes)} bytes."); + println("As string: ${String.fromBytes(bytes)}") + case Err(err) => println("Read error: ${err}") + } + } +``` + +## Appending to a File + +Use `append` to add text to an existing file without overwriting it. The file +is created if it does not exist: + +```flix +use Fs.FileRead +use Fs.FileWrite + +def main(): Unit \ { FileRead, FileWrite, IO } = + match FileWrite.write(str = "Line 1\n", "log.txt") { + case Err(err) => println("Write error: ${err}") + case Ok(_) => + match FileWrite.append(str = "Line 2\n", "log.txt") { + case Err(err) => println("Append error: ${err}") + case Ok(_) => + match FileRead.read("log.txt") { + case Ok(content) => println(content) + case Err(err) => println("Read error: ${err}") + } + } + } +``` + +There are also `appendLines` and `appendBytes` variants. + +## Listing a Directory + +Use `DirList.list` to get the names of all files and directories in a +directory: + +```flix +use Fs.DirList + +def main(): Unit \ { DirList, IO } = + match DirList.list(".") { + case Ok(entries) => + foreach (entry <- entries) { + println(entry) + } + case Err(err) => println("Error: ${err}") + } +``` + +## Finding Files with Glob + +Use `Glob.glob` to find files matching a glob pattern under a base directory: + +```flix +use Fs.Glob + +def main(): Unit \ { Glob, IO } = + match Glob.glob(".", "*.flix") { + case Ok(files) => + foreach (file <- files) { + println(file) + } + case Err(err) => println("Error: ${err}") + } +``` + +## File Metadata + +The `FileStat` effect provides operations for inspecting file metadata: +existence, type, size, permissions, and timestamps: + +```flix +use Fs.FileStat +use Fs.FileWrite + +def main(): Unit \ { FileStat, FileWrite, IO } = + let file = "example.txt"; + match FileWrite.write(str = "Hello!", file) { + case Err(err) => println("Write error: ${err}") + case Ok(_) => + match FileStat.exists(file) { + case Ok(b) => println("Exists: ${b}") + case Err(err) => println("Error: ${err}") + }; + match FileStat.isRegularFile(file) { + case Ok(b) => println("Is regular file: ${b}") + case Err(err) => println("Error: ${err}") + }; + match FileStat.isDirectory(file) { + case Ok(b) => println("Is directory: ${b}") + case Err(err) => println("Error: ${err}") + }; + match FileStat.size(file) { + case Ok(s) => println("Size: ${s}") + case Err(err) => println("Error: ${err}") + }; + match FileStat.modificationTime(file) { + case Ok(t) => println("Modification time: ${t}ms") + case Err(err) => println("Error: ${err}") + } + } +``` + +The `FileStat` effect combines four sub-effects: + +| Sub-effect | Operations | +|------------------|--------------------------------------------------------| +| `FileTest` | `exists`, `isDirectory`, `isRegularFile`, `isSymbolicLink` | +| `FilePermission` | `isReadable`, `isWritable`, `isExecutable` | +| `FileTime` | `accessTime`, `creationTime`, `modificationTime` | +| `FileSize` | `size` | + +## Copying, Moving, and Deleting + +The `FileWrite` effect also provides operations for copying, moving, and +deleting files: + +```flix +use Fs.FileWrite + +def main(): Unit \ { FileWrite, IO } = + match FileWrite.write(str = "Hello!", "original.txt") { + case Err(err) => println("Write error: ${err}") + case Ok(_) => + // Copy with no options. + match FileWrite.copy(src = "original.txt", "copy.txt") { + case Ok(_) => println("Copied.") + case Err(err) => println("Copy error: ${err}") + }; + // Move (rename) with no options. + match FileWrite.move(src = "copy.txt", "renamed.txt") { + case Ok(_) => println("Moved.") + case Err(err) => println("Move error: ${err}") + }; + // Delete. + match FileWrite.delete("renamed.txt") { + case Ok(_) => println("Deleted.") + case Err(err) => println("Delete error: ${err}") + } + } +``` + +The `copy` and `move` functions are convenience wrappers around `copyWith` and +`moveWith`, which accept option sets: + +- `CopyOption.CopyAttributes` — preserve file attributes +- `CopyOption.ReplaceExisting` — overwrite the destination if it exists +- `MoveOption.AtomicMove` — perform an atomic rename +- `MoveOption.ReplaceExisting` — overwrite the destination if it exists + +## Creating Directories + +Use `mkDir` to create a single directory, `mkDirs` to create a directory and +all its parents, and `mkTempDir` to create a temporary directory: + +```flix +use Fs.FileWrite + +def main(): Unit \ { FileWrite, IO } = + match FileWrite.mkDirs("a/b/c") { + case Ok(_) => println("Created a/b/c.") + case Err(err) => println("Error: ${err}") + }; + match FileWrite.mkTempDir("flix-") { + case Ok(path) => println("Temp dir: ${path}") + case Err(err) => println("Error: ${err}") + } +``` + +## The FileSystem Effect + +The `FileSystem` effect combines all filesystem operations into a single +effect. It includes all operations from `FileStat`, `FileRead`, `FileWrite`, +`DirList`, and `Glob`. Use `FileSystem` when you need multiple categories of +operations together: + +```flix +use Fs.FileSystem + +def main(): Unit \ { FileSystem, IO } = + match FileSystem.write(str = "Hello!", "greeting.txt") { + case Err(err) => println("Write error: ${err}") + case Ok(_) => + match FileSystem.read("greeting.txt") { + case Ok(content) => println("Read: ${content}") + case Err(err) => println("Read error: ${err}") + } + } +``` + +## The Effect Hierarchy + +The Flix filesystem effects form a hierarchy. At the top is `FileSystem` (29 +operations). Below it are intermediate effects that group related operations, +and at the bottom are leaf effects for individual operations: + +```text +FileSystem (29 ops — unified root) +├── FileStat (11 ops) +│ ├── FileTest (4 ops: exists, isDirectory, isRegularFile, isSymbolicLink) +│ ├── FilePermission (3 ops: isReadable, isWritable, isExecutable) +│ ├── FileTime (3 ops: accessTime, creationTime, modificationTime) +│ └── FileSize (1 op: size) +├── FileRead (3 ops: read, readLines, readBytes) +├── DirList (1 op: list) +├── Glob (1 op: glob) +└── FileWrite (13 ops: write, append, delete, copy, move, mkdir, etc.) +``` + +You can use any level of the hierarchy. Use a leaf effect like `FileExists` +when you only need `exists`. Use `FileRead` when you need to read files. Use +`FileSystem` when you need everything. + +Leaf effects can be run into their parent effects using `runWith` handlers. +For example, `FileExists` can be run into `FileTest`, and `ReadFile` into +`FileRead`: + +```flix +use Fs.FileExists +use Fs.FileRead +use Fs.FileTest +use Fs.ReadFile + +def main(): Unit \ { FileRead, FileTest, IO } = + run { + safeRead("example.txt") + } with FileExists.runWithFileTest + with ReadFile.runWithFileRead + +def safeRead(file: String): Unit \ { FileExists, ReadFile, IO } = + match FileExists.exists(file) { + case Err(err) => println("Error: ${err}") + case Ok(false) => println("File does not exist") + case Ok(true) => + match ReadFile.read(file) { + case Ok(content) => println(content) + case Err(err) => println("Read error: ${err}") + } + } +``` + +## Middleware + +Middleware are effect handlers that intercept filesystem operations. They are +applied using `run { ... } with FileSystem.` (or the corresponding +sub-effect module) and compose by stacking multiple `with` clauses. + +### Base Directory + +`withBaseDir` resolves relative paths against a base directory. Absolute paths +pass through unchanged: + +```flix +use Fs.FileSystem + +def main(): Unit \ { FileSystem, IO } = + match FileSystem.mkDirs("/tmp/flix-basedir") { + case Err(err) => println("Setup error: ${err}") + case Ok(_) => + run { + match FileSystem.write(str = "Hello", "greeting.txt") { + case Err(err) => println("Write error: ${err}") + case Ok(_) => + match FileSystem.read("greeting.txt") { + case Ok(content) => println("Read: ${content}") + case Err(err) => println("Read error: ${err}") + } + } + } with FileSystem.withBaseDir("/tmp/flix-basedir") + } +``` + +### Chroot + +`withChroot` restricts all operations to a directory subtree. Operations +targeting paths outside the chroot fail with a `PermissionDenied` error: + +```flix +use Fs.FileSystem + +def main(): Unit \ { FileSystem, IO } = + match FileSystem.mkDirs("/tmp/flix-chroot") { + case Err(err) => println("Setup error: ${err}") + case Ok(_) => + run { + match FileSystem.write(str = "Hello", "/tmp/flix-chroot/data.txt") { + case Ok(_) => println("Write inside chroot succeeded") + case Err(err) => println("Error: ${err}") + }; + match FileSystem.read("/etc/hostname") { + case Ok(_) => println("Unexpected: read outside chroot succeeded") + case Err(err) => println("Read outside chroot blocked: ${err}") + } + } with FileSystem.withChroot("/tmp/flix-chroot") + } +``` + +### Logging + +`withLogging` logs each filesystem operation via the `Logger` effect. Note +that `Logger` appears in the type signature of `main`: + +```flix +use Fs.FileSystem + +def main(): Unit \ { FileSystem, Logger, IO } = + run { + match FileSystem.write(str = "Hello, Flix!", "greeting.txt") { + case Err(err) => println("Write error: ${err}") + case Ok(_) => + match FileSystem.read("greeting.txt") { + case Ok(content) => println(content) + case Err(err) => println("Read error: ${err}") + } + } + } with FileSystem.withLogging +``` + +### Read-Only + +`withReadOnly` blocks all write operations with a `PermissionDenied` error. +Read and stat operations pass through normally: + +```flix +use Fs.FileSystem + +def main(): Unit \ { FileSystem, IO } = + run { + match FileSystem.write(str = "This will fail", "blocked.txt") { + case Ok(_) => println("Unexpected: write succeeded") + case Err(err) => println("Write blocked: ${err}") + }; + match FileSystem.exists("blocked.txt") { + case Ok(b) => println("Exists: ${b}") + case Err(err) => println("Error: ${err}") + } + } with FileSystem.withReadOnly +``` + +### Dry Run + +`withDryRun` logs write operations via the `Logger` effect without performing +them. Read operations still execute normally: + +```flix +use Fs.FileSystem + +def main(): Unit \ { FileSystem, Logger, IO } = + run { + match FileSystem.write(str = "This won't be written", "phantom.txt") { + case Err(err) => println("Write error: ${err}") + case Ok(_) => + match FileSystem.exists("phantom.txt") { + case Ok(b) => println("Exists: ${b}") + case Err(err) => println("Error: ${err}") + } + } + } with FileSystem.withDryRun +``` + +### Atomic Write + +`withAtomicWrite` writes data to a temporary file first, then atomically +renames it into place. This prevents partial writes on failure. Only `write`, +`writeLines`, and `writeBytes` are affected — appends and other operations +pass through unchanged: + +```flix +use Fs.FileSystem + +def main(): Unit \ { FileSystem, IO } = + run { + match FileSystem.write(str = "Atomic content", "output.txt") { + case Ok(_) => println("Atomic write succeeded.") + case Err(err) => println("Write error: ${err}") + } + } with FileSystem.withAtomicWrite +``` + +### Backup + +`withBackup` creates a backup copy of existing files before overwriting them. +Before each destructive operation (`write`, `writeLines`, `writeBytes`, +`truncate`, `delete`, `copyWith`, `moveWith`), the existing file is copied to +`file + suffix`: + +```flix +use Fs.FileSystem + +def main(): Unit \ { FileSystem, IO } = + match FileSystem.write(str = "Original content", "data.txt") { + case Err(err) => println("Setup error: ${err}") + case Ok(_) => + run { + match FileSystem.write(str = "New content", "data.txt") { + case Ok(_) => println("Write succeeded; backup saved to data.txt.bak") + case Err(err) => println("Write error: ${err}") + } + } with FileSystem.withBackup(".bak") + } +``` + +### Create Parent Directories + +`withMkParentDirs` automatically creates parent directories before write +and append operations. If the parent directory already exists, this is a +no-op: + +```flix +use Fs.FileSystem + +def main(): Unit \ { FileSystem, IO } = + run { + match FileSystem.write(str = "Hello", "deep/nested/path/greeting.txt") { + case Ok(_) => println("Write succeeded (parents created).") + case Err(err) => println("Write error: ${err}") + } + } with FileSystem.withMkParentDirs +``` + +### Conflict Check + +`withConflictCheck` tracks file modification times and rejects writes when the +file has been modified externally since the last operation. This catches +write-write conflicts from external processes: + +```flix +use Fs.FileSystem + +def main(): Unit \ { FileSystem, IO } = + run { + match FileSystem.write(str = "First write", "shared.txt") { + case Err(err) => println("Error: ${err}") + case Ok(_) => + match FileSystem.write(str = "Second write", "shared.txt") { + case Ok(_) => println("No conflict detected.") + case Err(err) => println("Conflict: ${err}") + } + } + } with FileSystem.withConflictCheck +``` + +### Size Rotation + +`withSizeRotation` automatically rotates files when they reach a size +threshold. Before append operations, the file's size is checked. If it meets +or exceeds `maxSize`, existing rotated files are shifted (`file.1` -> `file.2`, +etc.) and the current file is moved to `file.1`: + +```flix +use Fs.FileSystem +use Fs.Size + +def main(): Unit \ { FileSystem, IO } = + run { + foreach (i <- List.range(1, 20)) { + discard FileSystem.append(str = "Log entry ${i}\n", "app.log") + } + } with FileSystem.withSizeRotation(Size.kiloBytes(1), 3) +``` + +### Transfer Limit + +`withTransferLimit` rejects read or write operations where the payload exceeds +a maximum size: + +```flix +use Fs.FileSystem +use Fs.Size + +def main(): Unit \ { FileSystem, IO } = + run { + match FileSystem.write(str = "Small", "ok.txt") { + case Ok(_) => println("Small write succeeded.") + case Err(err) => println("Error: ${err}") + } + } with FileSystem.withTransferLimit(Size.megaBytes(10)) +``` + +### Access Control + +Flix provides middleware for restricting which paths can be accessed: + +- `withAllowList(dirs)` — only paths within the listed directories are allowed +- `withDenyList(dirs)` — paths within the listed directories are blocked +- `withAllowGlob(patterns)` — only paths matching at least one pattern are allowed +- `withDenyGlob(patterns)` — paths matching any pattern are blocked + +```flix +use Fs.FileSystem + +def main(): Unit \ { FileSystem, IO } = + run { + match FileSystem.read("/tmp/safe/data.txt") { + case Ok(content) => println(content) + case Err(err) => println("Error: ${err}") + } + } with FileSystem.withAllowList(Nel.of("/tmp/safe")) +``` + +### In-Memory Filesystem + +`withInMemoryFS` is a terminal handler that replaces the real filesystem with +a fully in-memory implementation. The filesystem starts empty; reads of +non-written files return `NotFound`. No real filesystem access occurs: + +```flix +use Fs.FileSystem +use Time.Clock + +def main(): Unit \ { Clock, IO } = + run { + let result = forM ( + _ <- FileSystem.mkDirs("/data"); + _ <- FileSystem.write(str = "Hello", "/data/hello.txt"); + _ <- FileSystem.write(str = "World", "/data/world.txt"); + entries <- FileSystem.list("/data"); + content <- FileSystem.read("/data/hello.txt"); + _ <- FileSystem.delete("/data/hello.txt"); + exists <- FileSystem.exists("/data/hello.txt") + ) yield (entries, content, exists); + match result { + case Err(err) => println("Error: ${err}") + case Ok((entries, content, exists)) => + println("Files in /data:"); + foreach (entry <- entries) { + println(" ${entry}") + }; + println("Content: ${content}"); + println("Exists after delete: ${exists}") + } + } with FileSystem.withInMemoryFS +``` + +Note that `withInMemoryFS` requires the `Clock` effect (for file timestamps) +but removes `FileSystem` from the effect signature since it fully handles it. + +### Memory Overlay + +`withMemoryOverlay` layers an in-memory writable store on top of the real +filesystem. Writes are captured in memory and subsequent reads see the written +data, but the real filesystem is never modified. Reads of files not in the +overlay fall through to the real filesystem: + +```flix +use Fs.FileSystem + +def main(): Unit \ { FileSystem, IO } = + run { + // This write is captured in memory, not written to disk. + match FileSystem.write(str = "In-memory only", "virtual.txt") { + case Err(err) => println("Error: ${err}") + case Ok(_) => + match FileSystem.read("virtual.txt") { + case Ok(content) => println("Read from overlay: ${content}") + case Err(err) => println("Error: ${err}") + } + } + } with FileSystem.withMemoryOverlay +``` + +## Composing Middleware + +Middleware compose by stacking `with` clauses. Each `with` wraps the preceding +block, so the outermost handler runs first. Here is an example that stacks base +directory, parent directory creation, backup, atomic writes, conflict checking, +and logging: + +```flix +use Fs.FileSystem + +def main(): Unit \ { FileSystem, Logger, IO } = + run { + match FileSystem.write(str = "Hello, Flix!", "data/greeting.txt") { + case Err(err) => println("Write error: ${err}") + case Ok(_) => + match FileSystem.read("data/greeting.txt") { + case Ok(content) => println("Read: ${content}") + case Err(err) => println("Read error: ${err}") + } + } + } with FileSystem.withBaseDir("/tmp/flix-example") + with FileSystem.withMkParentDirs + with FileSystem.withConflictCheck + with FileSystem.withBackup(".bak") + with FileSystem.withAtomicWrite + with FileSystem.withLogging +``` + +The `FileSystem`, `Logger`, and `Clock` effects all have default handlers, so +they are handled automatically when they appear in the type signature of +`main`. + +> **Note:** The order of `with` clauses matters. The outermost handler (listed +> last) wraps all inner handlers. In the example above, `withLogging` is +> outermost, so it sees *every* filesystem operation — including retries from +> conflict checks and temporary files from atomic writes. When composing +> middleware, think about which layer should observe which operations. + +## Middleware Summary + +The following table shows which middleware are available on which effects: + +| Middleware | FileTest | FilePermission | FileTime | FileStat | FileRead | DirList | Glob | FileWrite | FileSystem | +|------------------------|:--------:|:--------------:|:--------:|:--------:|:--------:|:-------:|:----:|:---------:|:----------:| +| `withLogging` | x | x | x | x | x | x | x | x | x | +| `withBaseDir` | x | x | x | x | x | x | x | x | x | +| `withChroot` | x | x | x | x | x | x | x | x | x | +| `withAllowList` | x | x | x | x | x | x | x | x | x | +| `withDenyList` | x | x | x | x | x | x | x | x | x | +| `withAllowGlob` | x | x | x | x | x | x | x | x | x | +| `withDenyGlob` | x | x | x | x | x | x | x | x | x | +| `withFollowLinks` | x | x | x | x | x | x | x | x | x | +| `withTransferLimit` | | | | | x | | | x | x | +| `withChecksum` | | | | | x | | | x | x | +| `withDryRun` | | | | | | | | x | x | +| `withReadOnly` | | | | | | | | x | x | +| `withAtomicWrite` | | | | | | | | x | x | +| `withBackup` | | | | | | | | x | x | +| `withConflictCheck` | | | | | | | | x | x | +| `withMkParentDirs` | | | | | | | | x | x | +| `withSizeRotation` | | | | | | | | x | x | +| `withMemoryOverlay` | | | | | | | | | x | +| `withInMemoryFS` | | | | | | | | | x | diff --git a/src/library-effects.md b/src/library-effects.md index 7d0ef5c5..c49ae5c0 100644 --- a/src/library-effects.md +++ b/src/library-effects.md @@ -9,6 +9,7 @@ effects all have default handlers, so no explicit `runWithIO` is needed in | [`Assert`](./assert.md) | Runtime assertions (`assertTrue`, `assertEq`, etc.) with configurable handlers. | | [`Logger`](./logger.md) | Structured logging at five severity levels with filtering and collection. | | [`Math.Random`](./random.md) | Generating pseudorandom numbers, with optional seeded determinism. | +| [`Fs.FileSystem`](./filesystem.md) | File I/O, metadata, directories, and middleware (chroot, atomic writes, in-memory FS, etc.). | | [`Net.Http` / `Net.Https`](./http-and-https.md) | Sending HTTP requests with a fluent API, middleware (retries, rate limiting, circuit breakers). | | [`Sys.Console`](./console.md) | Terminal I/O: reading input, printing to stdout/stderr, prompts, and menus. | | [`Sys.Env`](./env.md) | Accessing environment variables, system properties, and platform information. | From ec451f5c7926fe07e4fae253dec3d0534da722b5 Mon Sep 17 00:00:00 2001 From: Magnus Madsen Date: Sun, 22 Mar 2026 08:05:26 +0100 Subject: [PATCH 2/3] refactor: improve filesystem page prose, fix outdated examples - Reword filesystem.md to use informal "we" language throughout - Add IoError explanation with abbreviated ErrorKind table - Move Effect Hierarchy section after Middleware Summary - Move Reading a File before Writing a File - Add notes about IO effect and combined effect sets - Update introduction.md to use Http composing middleware example - Update next-steps.md to use FileRead with default handlers - Update library-effects.md with line-broken effect names Co-Authored-By: Claude Opus 4.6 (1M context) --- src/filesystem.md | 199 ++++++++++++++++++++++------------------- src/introduction.md | 41 +++++---- src/library-effects.md | 4 +- src/next-steps.md | 53 +++++------ 4 files changed, 154 insertions(+), 143 deletions(-) diff --git a/src/filesystem.md b/src/filesystem.md index f32c0d37..887b9506 100644 --- a/src/filesystem.md +++ b/src/filesystem.md @@ -14,41 +14,56 @@ are: All effects have default handlers, so no explicit `runWithIO` call is needed in `main`. -## Writing a File +There are also more fine-grained leaf effects (e.g. `FileExists`, +`ReadFile`, `WriteFile`) that do not have default handlers but can be run into +their parent effects using `runWith` handlers. See [The Effect +Hierarchy](#the-effect-hierarchy) for details. + +## Reading a File -The simplest way to write a file is with `FileWrite.write`. It takes a named -`str` argument and a path, and returns `Result[IoError, Unit]`: +We can use `FileRead.read` to read an entire file as a string: ```flix -use Fs.FileWrite +use Fs.FileRead -def main(): Unit \ { FileWrite, IO } = - match FileWrite.write(str = "Hello, Flix!", "greeting.txt") { - case Ok(_) => println("File written successfully.") - case Err(err) => println("Error: ${err}") +def main(): Unit \ { FileRead, IO } = + match FileRead.read("example.txt") { + case Ok(content) => println(content) + case Err(err) => println("Error: ${err}") } ``` -The `FileWrite` effect appears in the type signature of `main` alongside `IO`. +All filesystem operations return `Result[IoError, ...]`. The `IoError` type is +a pair of an `ErrorKind` and a message string. The `ErrorKind` enum tells us +what went wrong: -## Reading a File +| ErrorKind | Description | +|--------------------------|--------------------------------------------------| +| `NotFound` | The file or directory was not found. | +| `AlreadyExists` | The file or directory already exists. | +| `PermissionDenied` | Access was denied (also used by middleware). | +| `InvalidPath` | The path is malformed. | +| ... | and others. | -Use `FileRead.read` to read an entire file as a string: +> **Note:** The `IO` effect appears in the signature because of `println`. + +## Writing a File + +We can use `FileWrite.write` to write a string to a file: ```flix -use Fs.FileRead +use Fs.FileWrite -def main(): Unit \ { FileRead, IO } = - match FileRead.read("example.txt") { - case Ok(content) => println(content) - case Err(err) => println("Error: ${err}") +def main(): Unit \ { FileWrite, IO } = + match FileWrite.write(str = "Hello, Flix!", "greeting.txt") { + case Ok(_) => println("File written successfully.") + case Err(err) => println("Error: ${err}") } ``` ## Reading and Writing Lines -Use `readLines` and `writeLines` to work with files line by line. The `lines` -argument is a `List[String]`: +We can use `readLines` and `writeLines` to work with files line by line: ```flix use Fs.FileRead @@ -68,10 +83,12 @@ def main(): Unit \ { FileRead, FileWrite, IO } = } ``` +> **Note:** Since we both read and write, the effect set includes `FileRead`, +> `FileWrite`, and `IO`. + ## Reading and Writing Bytes -Use `readBytes` and `writeBytes` for binary data. `writeBytes` takes a -`Vector[Int8]` directly, and `readBytes` returns one: +We can use `readBytes` and `writeBytes` for binary data: ```flix use Fs.FileRead @@ -93,8 +110,8 @@ def main(): Unit \ { FileRead, FileWrite, IO } = ## Appending to a File -Use `append` to add text to an existing file without overwriting it. The file -is created if it does not exist: +We can use `append` to add text to an existing file without overwriting it. The +file is created if it does not exist: ```flix use Fs.FileRead @@ -119,7 +136,7 @@ There are also `appendLines` and `appendBytes` variants. ## Listing a Directory -Use `DirList.list` to get the names of all files and directories in a +We can use `DirList.list` to get the names of all files and directories in a directory: ```flix @@ -137,7 +154,8 @@ def main(): Unit \ { DirList, IO } = ## Finding Files with Glob -Use `Glob.glob` to find files matching a glob pattern under a base directory: +We can use `Glob.glob` to find files matching a glob pattern under a base +directory: ```flix use Fs.Glob @@ -154,8 +172,8 @@ def main(): Unit \ { Glob, IO } = ## File Metadata -The `FileStat` effect provides operations for inspecting file metadata: -existence, type, size, permissions, and timestamps: +We can use the `FileStat` effect to inspect file metadata: existence, type, +size, permissions, and timestamps: ```flix use Fs.FileStat @@ -200,8 +218,7 @@ The `FileStat` effect combines four sub-effects: ## Copying, Moving, and Deleting -The `FileWrite` effect also provides operations for copying, moving, and -deleting files: +We can also use the `FileWrite` effect to copy, move, and delete files: ```flix use Fs.FileWrite @@ -238,8 +255,8 @@ The `copy` and `move` functions are convenience wrappers around `copyWith` and ## Creating Directories -Use `mkDir` to create a single directory, `mkDirs` to create a directory and -all its parents, and `mkTempDir` to create a temporary directory: +We can use `mkDir` to create a single directory, `mkDirs` to create a directory +and all its parents, and `mkTempDir` to create a temporary directory: ```flix use Fs.FileWrite @@ -259,8 +276,8 @@ def main(): Unit \ { FileWrite, IO } = The `FileSystem` effect combines all filesystem operations into a single effect. It includes all operations from `FileStat`, `FileRead`, `FileWrite`, -`DirList`, and `Glob`. Use `FileSystem` when you need multiple categories of -operations together: +`DirList`, and `Glob`. We can use `FileSystem` when we need multiple categories +of operations together: ```flix use Fs.FileSystem @@ -276,62 +293,11 @@ def main(): Unit \ { FileSystem, IO } = } ``` -## The Effect Hierarchy - -The Flix filesystem effects form a hierarchy. At the top is `FileSystem` (29 -operations). Below it are intermediate effects that group related operations, -and at the bottom are leaf effects for individual operations: - -```text -FileSystem (29 ops — unified root) -├── FileStat (11 ops) -│ ├── FileTest (4 ops: exists, isDirectory, isRegularFile, isSymbolicLink) -│ ├── FilePermission (3 ops: isReadable, isWritable, isExecutable) -│ ├── FileTime (3 ops: accessTime, creationTime, modificationTime) -│ └── FileSize (1 op: size) -├── FileRead (3 ops: read, readLines, readBytes) -├── DirList (1 op: list) -├── Glob (1 op: glob) -└── FileWrite (13 ops: write, append, delete, copy, move, mkdir, etc.) -``` - -You can use any level of the hierarchy. Use a leaf effect like `FileExists` -when you only need `exists`. Use `FileRead` when you need to read files. Use -`FileSystem` when you need everything. - -Leaf effects can be run into their parent effects using `runWith` handlers. -For example, `FileExists` can be run into `FileTest`, and `ReadFile` into -`FileRead`: - -```flix -use Fs.FileExists -use Fs.FileRead -use Fs.FileTest -use Fs.ReadFile - -def main(): Unit \ { FileRead, FileTest, IO } = - run { - safeRead("example.txt") - } with FileExists.runWithFileTest - with ReadFile.runWithFileRead - -def safeRead(file: String): Unit \ { FileExists, ReadFile, IO } = - match FileExists.exists(file) { - case Err(err) => println("Error: ${err}") - case Ok(false) => println("File does not exist") - case Ok(true) => - match ReadFile.read(file) { - case Ok(content) => println(content) - case Err(err) => println("Read error: ${err}") - } - } -``` - ## Middleware -Middleware are effect handlers that intercept filesystem operations. They are -applied using `run { ... } with FileSystem.` (or the corresponding -sub-effect module) and compose by stacking multiple `with` clauses. +Middleware are effect handlers that intercept filesystem operations. We apply +them using `run { ... } with FileSystem.` (or the corresponding +sub-effect module) and compose them by stacking multiple `with` clauses. ### Base Directory @@ -567,7 +533,7 @@ def main(): Unit \ { FileSystem, IO } = ### Access Control -Flix provides middleware for restricting which paths can be accessed: +Flix provides middleware for restricting which paths can be accessed. We can use: - `withAllowList(dirs)` — only paths within the listed directories are allowed - `withDenyList(dirs)` — paths within the listed directories are blocked @@ -649,10 +615,10 @@ def main(): Unit \ { FileSystem, IO } = ## Composing Middleware -Middleware compose by stacking `with` clauses. Each `with` wraps the preceding -block, so the outermost handler runs first. Here is an example that stacks base -directory, parent directory creation, backup, atomic writes, conflict checking, -and logging: +We can compose middleware by stacking `with` clauses. Each `with` wraps the +preceding block, so the outermost handler runs first. Here is an example that +stacks base directory, parent directory creation, backup, atomic writes, +conflict checking, and logging: ```flix use Fs.FileSystem @@ -710,3 +676,54 @@ The following table shows which middleware are available on which effects: | `withSizeRotation` | | | | | | | | x | x | | `withMemoryOverlay` | | | | | | | | | x | | `withInMemoryFS` | | | | | | | | | x | + +## The Effect Hierarchy + +The Flix filesystem effects form a hierarchy. At the top is `FileSystem` with +all 29 operations. Below it are intermediate effects that group related +operations, and at the bottom are leaf effects for individual operations: + +```text +FileSystem (29 ops — unified root) +├── FileStat (11 ops) +│ ├── FileTest (4 ops: exists, isDirectory, isRegularFile, isSymbolicLink) +│ ├── FilePermission (3 ops: isReadable, isWritable, isExecutable) +│ ├── FileTime (3 ops: accessTime, creationTime, modificationTime) +│ └── FileSize (1 op: size) +├── FileRead (3 ops: read, readLines, readBytes) +├── DirList (1 op: list) +├── Glob (1 op: glob) +└── FileWrite (13 ops: write, append, delete, copy, move, mkdir, etc.) +``` + +We can use any level of the hierarchy. For example, we can use a leaf effect +like `FileExists` when we only need `exists`, `FileRead` when we need to read +files, or `FileSystem` when we need everything. + +We can run leaf effects into their parent effects using `runWith` handlers. +For example, we can run `FileExists` into `FileTest` and `ReadFile` into +`FileRead`: + +```flix +use Fs.FileExists +use Fs.FileRead +use Fs.FileTest +use Fs.ReadFile + +def main(): Unit \ { FileRead, FileTest, IO } = + run { + safeRead("example.txt") + } with FileExists.runWithFileTest + with ReadFile.runWithFileRead + +def safeRead(file: String): Unit \ { FileExists, ReadFile, IO } = + match FileExists.exists(file) { + case Err(err) => println("Error: ${err}") + case Ok(false) => println("File does not exist") + case Ok(true) => + match ReadFile.read(file) { + case Ok(content) => println(content) + case Err(err) => println("Read error: ${err}") + } + } +``` diff --git a/src/introduction.md b/src/introduction.md index 832381ac..374d1a7e 100644 --- a/src/introduction.md +++ b/src/introduction.md @@ -108,27 +108,34 @@ Here is an example that uses built-in **effects and handlers**: ```flix use Net.Http +use Net.Retry use Net.HttpResponse +use Time.Clock +use Time.Duration.{milliseconds, seconds} -def main(): Unit \ { Http, Logger, IO } = +def main(): Unit \ { Clock, Http, Logger, IO } = + let defaultHeaders = Map#{ + "Accept" => List#{"application/json"}, + "Authorization" => List#{"Bearer tok123"} + }; run { - let url = "http://example.com/"; - Logger.info("Downloading URL: '${url}'"); - match Http.get(url) { - case Ok(resp) => - let file = "data.txt"; - Logger.info("Saving response to file: '${file}'"); - let body = HttpResponse.body(resp); - match FileWriteWithResult.write(str = body, file) { - case Ok(_) => - Logger.info("Response saved to file: '${file}'") - case Err(err) => - Logger.fatal("Unable to write file: '${err}'") - } - case Err(err) => - Logger.fatal("Unable to download URL: '${err}'") + let urls = List#{"/api/users", "/api/posts"}; + foreach (url <- urls) { + match Http.get(url) { + case Ok(resp) => println("${url} -> ${HttpResponse.status(resp)}") + case Err(err) => println("${url} -> ${err}") + } + }; + match Http.get("https://notfound.flix.dev/") { + case Ok(resp) => println("notfound -> ${HttpResponse.status(resp)}") + case Err(err) => println("notfound -> ${err}") } - } with FileWriteWithResult.runWithIO + } with Http.withBaseUrl("https://flix.dev") + with Http.withDefaultHeaders(defaultHeaders) + with Http.withRetry(Retry.linear(maxRetries = 2, delay = milliseconds(100))) + with Http.withCircuitBreaker(failureThreshold = 3, cooldown = seconds(5)) + with Http.withSlidingWindow(maxRequests = 2, window = seconds(1)) + with Http.withLogging ``` Here is an example that **defines its own effects and handlers**: diff --git a/src/library-effects.md b/src/library-effects.md index c49ae5c0..8b1f138c 100644 --- a/src/library-effects.md +++ b/src/library-effects.md @@ -9,8 +9,8 @@ effects all have default handlers, so no explicit `runWithIO` is needed in | [`Assert`](./assert.md) | Runtime assertions (`assertTrue`, `assertEq`, etc.) with configurable handlers. | | [`Logger`](./logger.md) | Structured logging at five severity levels with filtering and collection. | | [`Math.Random`](./random.md) | Generating pseudorandom numbers, with optional seeded determinism. | -| [`Fs.FileSystem`](./filesystem.md) | File I/O, metadata, directories, and middleware (chroot, atomic writes, in-memory FS, etc.). | -| [`Net.Http` / `Net.Https`](./http-and-https.md) | Sending HTTP requests with a fluent API, middleware (retries, rate limiting, circuit breakers). | +| [`Fs.FileSystem`](./filesystem.md)
[`Fs.FileRead`](./filesystem.md)
[`Fs.FileWrite`](./filesystem.md)
[`Fs.FileStat`](./filesystem.md) | File I/O, metadata, directories, and middleware (chroot, atomic writes, in-memory FS, etc.). | +| [`Net.Http`](./http-and-https.md)
[`Net.Https`](./http-and-https.md) | Sending HTTP requests with a fluent API, middleware (retries, rate limiting, circuit breakers). | | [`Sys.Console`](./console.md) | Terminal I/O: reading input, printing to stdout/stderr, prompts, and menus. | | [`Sys.Env`](./env.md) | Accessing environment variables, system properties, and platform information. | | [`Sys.Exit`](./exit.md) | Terminating the program with a specific exit code. | diff --git a/src/next-steps.md b/src/next-steps.md index 4f56eb88..4c9ab6c8 100644 --- a/src/next-steps.md +++ b/src/next-steps.md @@ -8,53 +8,40 @@ UNIX. We will use the opportunity to illustrate how to use algebraic effects in Flix. ```flix -use Sys.Console - -def wc(file: String): Unit \ {Console, FileReadWithResult} = { - match FileReadWithResult.readLines(file) { - case Ok(lines) => - let totalLines = List.length(lines); - let totalWords = List.sumWith(numberOfWords, lines); - Console.println("Lines: ${totalLines}, Words: ${totalWords}") - case Err(_) => - Console.println("Unable to read file: ${file}") - } -} +use Fs.FileRead + +def wc(file: String): Unit \ { FileRead, IO } = + match FileRead.readLines(file) { + case Ok(lines) => + let totalLines = List.length(lines); + let totalWords = List.sumWith(numberOfWords, lines); + println("Lines: ${totalLines}, Words: ${totalWords}") + case Err(_) => + println("Unable to read file: ${file}") + } def numberOfWords(s: String): Int32 = s |> String.words |> List.length -def main(): Unit \ IO = - run { - wc("Main.flix") - } with Console.runWithIO - with FileReadWithResult.runWithIO - +def main(): Unit \ { FileRead, IO } = + wc("Main.flix") ``` The program works as follows: We define a `wc` function that takes a filename and reads all lines from the -file using the algebraic effect `FileReadWithResult`. +file using the `FileRead` effect. If the file is successfully read, we calculate: - The number of lines using `List.length`. - The number of words by summing the results of applying `numberOfWords` to each - line. - -The results are printed to the terminal using the `Console` algebraic effect. + line. -If the file cannot be read, an error message is printed to the terminal using -the same effect. +The results are printed to the terminal using `println`. -The `wc` function's type and effect signature specifies the `{Console, -FileReadWithResult}` effect set, indicating these effects are required. -Conceptually, the function is pure except for these effects, which must be -handled by the caller. +If the file cannot be read, an error message is printed instead. -The `main` function calls `wc` with a fixed filename. Since `wc` uses the -`Console` and `FileReadWithResult` effects, we must provide their -implementations. This is achieved using the `run-with` construct, where we -specify the default handlers `Console.runWithIO` and -`FileReadWithResult.runWithIO`. +The `wc` function's type and effect signature specifies the `{FileRead, IO}` +effect set, indicating these effects are required. Both `FileRead` and `IO` have +default handlers, so no explicit handler calls are needed in `main`. From 2f8eefecc6a98aeccacf444d63930e6454f99361 Mon Sep 17 00:00:00 2001 From: Magnus Madsen Date: Sun, 22 Mar 2026 08:12:39 +0100 Subject: [PATCH 3/3] refactor: polish filesystem page prose and fix minor issues - Remove Size Rotation section - Fix middleware composition description (innermost handler runs first) - Fix incorrect Clock effect mention in composing middleware section - Reword In-Memory Filesystem and Memory Overlay introductions - Add scroll hint to middleware summary table Co-Authored-By: Claude Opus 4.6 (1M context) --- src/filesystem.md | 47 ++++++++++++++--------------------------------- 1 file changed, 14 insertions(+), 33 deletions(-) diff --git a/src/filesystem.md b/src/filesystem.md index 887b9506..001135a3 100644 --- a/src/filesystem.md +++ b/src/filesystem.md @@ -494,25 +494,6 @@ def main(): Unit \ { FileSystem, IO } = } with FileSystem.withConflictCheck ``` -### Size Rotation - -`withSizeRotation` automatically rotates files when they reach a size -threshold. Before append operations, the file's size is checked. If it meets -or exceeds `maxSize`, existing rotated files are shifted (`file.1` -> `file.2`, -etc.) and the current file is moved to `file.1`: - -```flix -use Fs.FileSystem -use Fs.Size - -def main(): Unit \ { FileSystem, IO } = - run { - foreach (i <- List.range(1, 20)) { - discard FileSystem.append(str = "Log entry ${i}\n", "app.log") - } - } with FileSystem.withSizeRotation(Size.kiloBytes(1), 3) -``` - ### Transfer Limit `withTransferLimit` rejects read or write operations where the payload exceeds @@ -554,9 +535,9 @@ def main(): Unit \ { FileSystem, IO } = ### In-Memory Filesystem -`withInMemoryFS` is a terminal handler that replaces the real filesystem with -a fully in-memory implementation. The filesystem starts empty; reads of -non-written files return `NotFound`. No real filesystem access occurs: +The `withInMemoryFS` handler replaces the real filesystem with a fully +in-memory implementation. The filesystem starts empty; reads of non-written +files return `NotFound`. No real filesystem access occurs: ```flix use Fs.FileSystem @@ -591,9 +572,9 @@ but removes `FileSystem` from the effect signature since it fully handles it. ### Memory Overlay -`withMemoryOverlay` layers an in-memory writable store on top of the real -filesystem. Writes are captured in memory and subsequent reads see the written -data, but the real filesystem is never modified. Reads of files not in the +The `withMemoryOverlay` handler layers an in-memory writable store on top of +the real filesystem. Writes are captured in memory and subsequent reads see the +written data, but the real filesystem is never modified. Reads of files not in the overlay fall through to the real filesystem: ```flix @@ -615,10 +596,10 @@ def main(): Unit \ { FileSystem, IO } = ## Composing Middleware -We can compose middleware by stacking `with` clauses. Each `with` wraps the -preceding block, so the outermost handler runs first. Here is an example that -stacks base directory, parent directory creation, backup, atomic writes, -conflict checking, and logging: +We can compose middleware by stacking `with` clauses. The innermost handler +(listed first) intercepts the original operation, and then delegates to the +next outer handler. Here is an example that stacks base directory, parent +directory creation, backup, atomic writes, conflict checking, and logging: ```flix use Fs.FileSystem @@ -641,9 +622,8 @@ def main(): Unit \ { FileSystem, Logger, IO } = with FileSystem.withLogging ``` -The `FileSystem`, `Logger`, and `Clock` effects all have default handlers, so -they are handled automatically when they appear in the type signature of -`main`. +The `FileSystem` and `Logger` effects both have default handlers, so they are +handled automatically when they appear in the type signature of `main`. > **Note:** The order of `with` clauses matters. The outermost handler (listed > last) wraps all inner handlers. In the example above, `withLogging` is @@ -653,7 +633,8 @@ they are handled automatically when they appear in the type signature of ## Middleware Summary -The following table shows which middleware are available on which effects: +The following table shows which middleware are available on which effects +(scroll right to see the full table): | Middleware | FileTest | FilePermission | FileTime | FileStat | FileRead | DirList | Glob | FileWrite | FileSystem | |------------------------|:--------:|:--------------:|:--------:|:--------:|:--------:|:-------:|:----:|:---------:|:----------:|