A file handle lets you read and write files without worrying about when the OS opens or closes them. The file opens on the first read or write and closes automatically when the handle goes out of scope.
Inspecting the File System
file(path) creates a File handle without opening anything yet. f#format is the mode the handle will read and write in. Two of its values answer what the path IS — Directory, and NotExists for a path with nothing at it. The other three are modes you choose: TextFile (UTF-8 text, and the default for any file that exists), LittleEndian (binary, bytes stored least-significant first — common on most PCs) and BigEndian (binary, most-significant first — common in network protocols). It does not inspect content: a PNG you have not set a format on reads as TextFile, so #format cannot tell you a file is text. lines() reads a text file and returns it as a vector of lines. exists(path) is a convenience shorthand for checking that a path is not NotExists. delete(path) removes a file and returns a FileResult enum; use .ok() to check success. Clean up any leftover files from a previous interrupted run. #cwd opts this program into cwd-relative paths (CLI-tool semantics): a relative path resolves against the working directory, not the program's own directory. Without it, relative paths re-home next to the program (the portable-bundle default).
#cwd
fn cleanup() {
delete("test.bin");
delete("test2.bin");
delete("buffer.bin");
}
struct Buffer {
size: i32,
data: vector < single >
}
fn main() {
cleanup();
Asking for the format of a directory path returns Directory, not a file format. lines() reads the named text file and splits it on newlines.
ex = file("tests/example");
assert(ex#format == Directory, "example is a directory");
c = file("tests/example/config/terrain.txt").lines();
assert(c[1] == " terrain = [", "Line was '{c[1]}'");
exists and delete are safe to call when the file is absent. Nothing crashes: delete answers FileResult.NotFound, whose .ok() is false.
assert(!exists("test.bin"), "File should not exist before the test.");
assert(!delete("nonexistent_xyz.bin").ok(), "delete on missing file not ok");
Writing a Text or Binary File
Wrapping the file handle in a block ensures the file closes the moment the block ends. Set f#format to LittleEndian or BigEndian before writing binary data. Use f += value to append the raw bytes of any scalar value: u8, i8, u16, i16, u32, i32, integer, single, float, boolean, or text. Cast to the width you mean — an uncast integer writes 8 bytes, which is rarely the record width a binary format wants, so the compiler asks you to say it. Write as integer where the 8 bytes are deliberate.
{f = file("test.bin");
assert(f#format == NotExists, "File should not exist yet.");
f#format = BigEndian; // BigEndian: most-significant byte written first.
f += 0 as u8;
f += 1 as u8;
f += 0x203 as u16;
f += 0x4050607 as integer;
f += 0x8090a0b0c0d0e0f as integer;
f#size returns the total number of bytes written so far. An integer is 8 bytes, so the total is 1 + 1 + 2 + 8 + 8 = 20.
assert(f#size == 20, "Should have written 20 bytes.");
Text is written as raw UTF-8 bytes with no length prefix.
f += "Hello world!";
assert(f#size == 32, "Size should be 32 bytes (20 + 12).");
} // The file closes when the handle goes out of scope.
move(src, dst) renames a file. It refuses if the destination already exists.
assert(exists("test.bin"), "File should exist after writing.");
assert(move("test.bin", "test2.bin").ok(), "Could not move the file.");
Reading Back What You Wrote
When you open an existing file the default format is TextFile. Set f#format to match the format used when writing before you read any bytes. f#read(n) as T reads exactly n bytes and interprets them as type T. f#index is the byte offset where the last read started. f#next is the current read position; assign to it to seek anywhere.
{f = file("test2.bin");
assert(f#format == TextFile, "The default format is TextFile.");
f#format = LittleEndian;
The file was written as BigEndian, so reading the same 4 bytes as LittleEndian produces a byte-swapped value — that is intentional here and confirms that the bytes were stored in the order you chose.
v = f#read(4) as i32;
assert(v == 0x3020100, "BigEndian bytes 0..3 read as LittleEndian i32.");
assert(f#index == 0, "Last read started at byte 0.");
assert(f#next == 4, "Next read starts at byte 4.");
Seek to byte 20 to skip past the integers and read the text directly. (The u8+u8+u16+integer+integer header is 1+1+2+8+8 = 20 bytes, so the text starts there.)
f#next = 20;
s = f#read(5) as text;
assert(s == "Hello", "Partial text read from offset 20.");
assert(f#index == 20, "Last read started at byte 20.");
assert(f#next == 25, "Next position after 5-byte text read.");
Requesting more bytes than remain simply returns whatever is left.
rest = f#read(100) as text;
assert(rest == " world!", "Read continues to end of file.");
assert(f#next == f#size, "Position should be at end of file.");
You can seek back to any position and re-read.
f#next = 0;
assert(f#read(4) as i32 == 0x3020100, "Seek-and-reread matches original.");
}
assert(delete("test2.bin").ok(), "Could not remove the test file.");
Working with Vectors and Struct Data
f += vector<T> writes every element in sequence as raw bytes, which is the fastest way to dump a whole collection to disk. Setting f#size = n truncates or zero-extends the file to exactly n bytes — useful for discarding the tail after you have finished writing.
{f = file("buffer.bin");
f#format = LittleEndian;
ints =[1, 2, 3, 4];
f += ints;
assert(f#size == 32, "Four integers = 32 bytes (8 each)");
Truncate to the first two integers.
f#size = 16;
assert(f#size == 16, "Truncated to 16 bytes");
Growing works the same way, and the bytes you gain are zero.
f#size = 24;
f#next = 16;
assert(f#read(8) as integer == 0, "The extension reads as zero bytes.");
}
assert(delete("buffer.bin").ok(), "Could not remove buffer.bin after vector write test.");
Write a count followed by individual float values. sizeof(T) returns the byte width of a type, so you can compute the correct read length without hard-coding magic numbers.
{buf = file("buffer.bin");
buf#format = LittleEndian;
buf += 4 as i32;
buf += 1.1f;
buf += 1.2f;
buf += 2.1f;
buf += 2.2f;
}
Read the count, then use it to read exactly that many floats directly into a struct field. This pattern lets you serialise and deserialise structs with variable-length data cleanly. A sized f#read answers a raw byte buffer, so name the type you are reading it back as: the as is what turns those bytes into elements.
{b = Buffer { };
f = file("buffer.bin");
f#format = LittleEndian;
n = f#read(4) as i32;
floats = f#read(n * sizeof(single)) as vector<single>;
b.data = floats;
assert(len(b.data) == 4, "Read four floats back into the field.");
assert((b.data[0] ?? 0.0f) == 1.1f, "First float survives the round-trip.");
}
assert(delete("buffer.bin").ok(), "Could not remove buffer.bin.");
Directories
is_dir(path) and is_file(path) answer the question directly, without going through a handle. files() lists a directory as File handles, so every entry carries its own path and format; list_dir(path) gives you just the names. Both are sorted by name, so an index means the same entry on every machine — but they disagree on a path that is not a directory: files() answers an empty list and list_dir answers null, so discharge that one with ?? [].
dir = file("tests/example");
assert(is_dir("tests/example"), "the example directory is a directory");
assert(!is_file("tests/example"), "a directory is not a file");
entries = dir.files();
assert(len(entries) > 0, "the example directory is not empty");
assert((entries[0] ?? file("")).path == "tests/example/config", "first entry path");
mkdir(path) creates one level and mkdir_all(path) creates every missing level. mkdir_all is idempotent — a directory that already exists is Ok, which is what makes it safe to call on the way into a run.
assert(mkdir_all("tests/example").ok(), "mkdir_all on an existing directory is Ok");
assert(!mkdir("tests/example").ok(), "mkdir on an existing directory is not Ok");
delete is a file operation and answers FileResult.IsDirectory when you point it at a directory. rmdir is the one that removes a directory, and only an EMPTY one — it answers NotEmpty while entries remain. A recursive removal is yours to write: list_dir() the children, delete() the files, rmdir() the directory, deepest first.
Error handling
Filesystem operations that can fail return a FileResult, and it is an enum with six variants, not a boolean. .ok() is the shorthand for the Ok one; match when you need to tell the failures apart. The program does not crash on failure — you decide what to do.
result = delete("this_file_does_not_exist.txt");
assert(!result.ok(), "delete missing file returns not-ok");
why = match result {
Ok => "deleted",
NotFound => "there was nothing there",
PermissionDenied => "the OS refused",
IsDirectory => "that is a directory",
NotEmpty => "the directory still has entries in it",
Other => "it failed for another reason"
};
assert(why == "there was nothing there", "delete on a missing file is NotFound");
A handle to a path with nothing at it is safe to hold and safe to read. Nothing crashes: the format is NotExists and lines() is empty. A sized f#read on it answers null rather than a value you might mistake for data, and says so on stderr.
ghost = file("no_such_file.txt");
assert(ghost#format == NotExists, "non-existent file has NotExists format");
assert(len(ghost.lines()) == 0, "lines() on a missing file is empty");
move reports on the FILES, not on the shape of the path: a missing source is NotFound whether or not the path contains ...
assert(!move("any.txt", "../escape.txt").ok(), "move with a missing source fails");
One asymmetry to know: f += value is a statement and reports nothing. If the path cannot be opened for writing, the bytes are dropped and the run carries on with a message on stderr. Use f.write(text) when you need the write itself to answer a FileResult, and check .ok() after delete, move, mkdir, and mkdir_all.
}
Run it yourself
This page is a loft program. It runs here, in your browser — press Run, then ask it something.
Stop on a line
// Copyright (c) 2024-2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// @NAME: File
// @TITLE: File system — text, binary, and directory access.
// A file handle lets you read and write files without worrying about when the
// OS opens or closes them. The file opens on the first read or write and
// closes automatically when the handle goes out of scope.
// ## Inspecting the File System
// `file(path)` creates a File handle without opening anything yet.
// `f#format` is the mode the handle will read and write in. Two of its values
// answer what the path IS — Directory, and NotExists for a path with nothing at
// it. The other three are modes you choose: TextFile (UTF-8 text, and the default
// for any file that exists), LittleEndian (binary, bytes stored least-significant
// first — common on most PCs) and BigEndian (binary, most-significant first —
// common in network protocols). It does not inspect content: a PNG you have not
// set a format on reads as TextFile, so `#format` cannot tell you a file is text.
// `lines()` reads a text file and returns it as a vector of lines.
// `exists(path)` is a convenience shorthand for checking that a path is not
// NotExists. `delete(path)` removes a file and returns a `FileResult` enum;
// use `.ok()` to check success.
// Clean up any leftover files from a previous interrupted run.
// `#cwd` opts this program into cwd-relative paths (CLI-tool semantics): a
// relative path resolves against the working directory, not the program's own
// directory. Without it, relative paths re-home next to the program (the
// portable-bundle default).
#cwd
fn cleanup() {
delete("test.bin");
delete("test2.bin");
delete("buffer.bin");
}
struct Buffer {
size: i32,
data: vector < single >
}
fn main() {
cleanup();
// Asking for the format of a directory path returns Directory, not a file
// format. `lines()` reads the named text file and splits it on newlines.
ex = file("tests/example");
assert(ex#format == Directory, "example is a directory");
c = file("tests/example/config/terrain.txt").lines();
assert(c[1] == " terrain = [", "Line was '{c[1]}'");
// `exists` and `delete` are safe to call when the file is absent. Nothing
// crashes: `delete` answers `FileResult.NotFound`, whose `.ok()` is false.
assert(!exists("test.bin"), "File should not exist before the test.");
assert(!delete("nonexistent_xyz.bin").ok(), "delete on missing file not ok");
// ## Writing a Text or Binary File
// Wrapping the file handle in a block ensures the file closes the moment
// the block ends. Set `f#format` to LittleEndian or BigEndian before
// writing binary data. Use `f += value` to append the raw bytes of any
// scalar value: u8, i8, u16, i16, u32, i32, integer, single, float, boolean,
// or text. Cast to the width you mean — an uncast `integer` writes 8 bytes,
// which is rarely the record width a binary format wants, so the compiler
// asks you to say it. Write `as integer` where the 8 bytes are deliberate.
{f = file("test.bin");
assert(f#format == NotExists, "File should not exist yet.");
f#format = BigEndian; // BigEndian: most-significant byte written first.
f += 0 as u8;
f += 1 as u8;
f += 0x203 as u16;
f += 0x4050607 as integer;
f += 0x8090a0b0c0d0e0f as integer;
// `f#size` returns the total number of bytes written so far.
// An integer is 8 bytes, so the total is 1 + 1 + 2 + 8 + 8 = 20.
assert(f#size == 20, "Should have written 20 bytes.");
// Text is written as raw UTF-8 bytes with no length prefix.
f += "Hello world!";
assert(f#size == 32, "Size should be 32 bytes (20 + 12).");
} // The file closes when the handle goes out of scope.
// `move(src, dst)` renames a file. It refuses if the destination already
// exists.
assert(exists("test.bin"), "File should exist after writing.");
assert(move("test.bin", "test2.bin").ok(), "Could not move the file.");
// ## Reading Back What You Wrote
// When you open an existing file the default format is TextFile. Set
// `f#format` to match the format used when writing before you read any
// bytes. `f#read(n) as T` reads exactly n bytes and interprets them as
// type T. `f#index` is the byte offset where the last read started.
// `f#next` is the current read position; assign to it to seek anywhere.
{f = file("test2.bin");
assert(f#format == TextFile, "The default format is TextFile.");
f#format = LittleEndian;
// The file was written as BigEndian, so reading the same 4 bytes as
// LittleEndian produces a byte-swapped value — that is intentional here
// and confirms that the bytes were stored in the order you chose.
v = f#read(4) as i32;
assert(v == 0x3020100, "BigEndian bytes 0..3 read as LittleEndian i32.");
assert(f#index == 0, "Last read started at byte 0.");
assert(f#next == 4, "Next read starts at byte 4.");
// Seek to byte 20 to skip past the integers and read the text directly.
// (The u8+u8+u16+integer+integer header is 1+1+2+8+8 = 20 bytes, so the
// text starts there.)
f#next = 20;
s = f#read(5) as text;
assert(s == "Hello", "Partial text read from offset 20.");
assert(f#index == 20, "Last read started at byte 20.");
assert(f#next == 25, "Next position after 5-byte text read.");
// Requesting more bytes than remain simply returns whatever is left.
rest = f#read(100) as text;
assert(rest == " world!", "Read continues to end of file.");
assert(f#next == f#size, "Position should be at end of file.");
// You can seek back to any position and re-read.
f#next = 0;
assert(f#read(4) as i32 == 0x3020100, "Seek-and-reread matches original.");
}
assert(delete("test2.bin").ok(), "Could not remove the test file.");
// ## Working with Vectors and Struct Data
// `f += vector<T>` writes every element in sequence as raw bytes, which is
// the fastest way to dump a whole collection to disk. Setting `f#size = n`
// truncates or zero-extends the file to exactly n bytes — useful for
// discarding the tail after you have finished writing.
{f = file("buffer.bin");
f#format = LittleEndian;
ints =[1, 2, 3, 4];
f += ints;
assert(f#size == 32, "Four integers = 32 bytes (8 each)");
// Truncate to the first two integers.
f#size = 16;
assert(f#size == 16, "Truncated to 16 bytes");
// Growing works the same way, and the bytes you gain are zero.
f#size = 24;
f#next = 16;
assert(f#read(8) as integer == 0, "The extension reads as zero bytes.");
}
assert(delete("buffer.bin").ok(), "Could not remove buffer.bin after vector write test.");
// Write a count followed by individual float values. `sizeof(T)` returns
// the byte width of a type, so you can compute the correct read length
// without hard-coding magic numbers.
{buf = file("buffer.bin");
buf#format = LittleEndian;
buf += 4 as i32;
buf += 1.1f;
buf += 1.2f;
buf += 2.1f;
buf += 2.2f;
}
// Read the count, then use it to read exactly that many floats directly
// into a struct field. This pattern lets you serialise and deserialise
// structs with variable-length data cleanly. A sized `f#read` answers a
// raw byte buffer, so name the type you are reading it back as: the `as`
// is what turns those bytes into elements.
{b = Buffer { };
f = file("buffer.bin");
f#format = LittleEndian;
n = f#read(4) as i32;
floats = f#read(n * sizeof(single)) as vector<single>;
b.data = floats;
assert(len(b.data) == 4, "Read four floats back into the field.");
assert((b.data[0] ?? 0.0f) == 1.1f, "First float survives the round-trip.");
}
assert(delete("buffer.bin").ok(), "Could not remove buffer.bin.");
// ## Directories
// `is_dir(path)` and `is_file(path)` answer the question directly, without
// going through a handle. `files()` lists a directory as File handles, so
// every entry carries its own path and format; `list_dir(path)` gives you
// just the names. Both are sorted by name, so an index means the same entry
// on every machine — but they disagree on a path that is not a directory:
// `files()` answers an empty list and `list_dir` answers null, so discharge
// that one with `?? []`.
dir = file("tests/example");
assert(is_dir("tests/example"), "the example directory is a directory");
assert(!is_file("tests/example"), "a directory is not a file");
entries = dir.files();
assert(len(entries) > 0, "the example directory is not empty");
assert((entries[0] ?? file("")).path == "tests/example/config", "first entry path");
// `mkdir(path)` creates one level and `mkdir_all(path)` creates every missing
// level. `mkdir_all` is idempotent — a directory that already exists is Ok,
// which is what makes it safe to call on the way into a run.
assert(mkdir_all("tests/example").ok(), "mkdir_all on an existing directory is Ok");
assert(!mkdir("tests/example").ok(), "mkdir on an existing directory is not Ok");
// `delete` is a file operation and answers FileResult.IsDirectory when you
// point it at a directory. `rmdir` is the one that removes a directory, and
// only an EMPTY one — it answers NotEmpty while entries remain. A recursive
// removal is yours to write: list_dir() the children, delete() the files,
// rmdir() the directory, deepest first.
// ## Error handling
// Filesystem operations that can fail return a `FileResult`, and it is an enum
// with six variants, not a boolean. `.ok()` is the shorthand for the Ok one;
// match when you need to tell the failures apart. The program does not crash
// on failure — you decide what to do.
result = delete("this_file_does_not_exist.txt");
assert(!result.ok(), "delete missing file returns not-ok");
why = match result {
Ok => "deleted",
NotFound => "there was nothing there",
PermissionDenied => "the OS refused",
IsDirectory => "that is a directory",
NotEmpty => "the directory still has entries in it",
Other => "it failed for another reason"
};
assert(why == "there was nothing there", "delete on a missing file is NotFound");
// A handle to a path with nothing at it is safe to hold and safe to read.
// Nothing crashes: the format is NotExists and `lines()` is empty. A sized
// `f#read` on it answers null rather than a value you might mistake for data,
// and says so on stderr.
ghost = file("no_such_file.txt");
assert(ghost#format == NotExists, "non-existent file has NotExists format");
assert(len(ghost.lines()) == 0, "lines() on a missing file is empty");
// `move` reports on the FILES, not on the shape of the path: a missing source
// is NotFound whether or not the path contains `..`.
assert(!move("any.txt", "../escape.txt").ok(), "move with a missing source fails");
// One asymmetry to know: `f += value` is a statement and reports nothing. If
// the path cannot be opened for writing, the bytes are dropped and the run
// carries on with a message on stderr. Use `f.write(text)` when you need the
// write itself to answer a FileResult, and check `.ok()` after `delete`,
// `move`, `mkdir`, and `mkdir_all`.
}