Skip to main content

File I/O

Every recipe below uses core.io.fs and assumes the function is in scope for using [FileSystem, IO]. See stdlib/io for the full API.

Read a file line by line​

mount core.io.fs;
mount core.io.{BufReader};

fn count_errors(path: &Path) -> IoResult<Int>
using [FileSystem]
{
let file = File.open(path)?;
let mut reader = BufReader.new(file);
let mut count = 0;
for line in reader.lines() {
let line = line?; // IoResult<Text>
if line.starts_with("ERROR") { count += 1; }
}
Result.Ok(count)
}

Known limitation — this loop crashes under AOT. The interpreter prints every line; the AOT binary reads the file correctly and then faults inside BufReader.read_until on the first iteration, dereferencing the element VALUE where it expects a reference. Line-oriented reading is interpreter-only until that closes. read_to_string below is unaffected at both tiers, and so is Text.lines() on an already-read string — the defect is in the BufReader iteration, not in line splitting.

BufReader.new(file) wraps the file in an 8 KiB buffer by default; for sequential workloads where you know the file is large, preallocate:

let reader = BufReader.with_capacity(64 * 1024, file);

Read the whole file at once​

When the file fits comfortably in memory (say, under 10 MiB) and you need all of it, prefer the one-shot APIs:

// Bytes:
let bytes: List<Byte> = fs.read(path)?;

// Text (validates UTF-8 — errors if invalid):
let text: Text = fs.read_to_string(path)?;

// Lines — eagerly materialised:
let lines: List<Text> = fs.read_to_string(path)?
.lines()
.collect();

For files bigger than available memory, never use read_to_string — use BufReader-based streaming as above.

Write a file​

fn write_summary(path: &Path, summary: &Summary) -> IoResult<()>
using [FileSystem]
{
let mut w = BufWriter.new(File.create(path)?);
for record in &summary.records {
w.write_all(record.to_text().as_bytes())?;
w.write_all(b"\n")?;
}
w.flush()?; // don't forget!
Result.Ok(())
}

Atomic writes​

A crash between File.create and flush leaves a partial file. For critical data, write to a sibling file and rename it into place — POSIX guarantees rename is atomic within a filesystem:

fn write_atomic(path: &Path, contents: &[Byte]) -> IoResult<()>
using [FileSystem]
{
let tmp = path.with_extension("tmp");
{
let mut w = BufWriter.new(File.create(&tmp)?);
w.write_all(contents)?;
w.flush()?; // buffer -> File
w.get_ref().sync_data()?; // File -> disk
}
fs.rename(&tmp, path)?; // atomic
Result.Ok(())
}

sync_data forces the OS to push the data to stable storage; without it the data may live only in the page cache for some time after flush returns.

Append to a file​

let mut f = File.options()
.append(true)
.create(true)
.open(path)?;
f.write_all(line.as_bytes())?;

Pitfall — forgetting to flush​

BufWriter drops do flush, but swallow the error. For critical writes, call .flush()? explicitly so a failure propagates:

{
let mut w = BufWriter.new(File.create(path)?);
w.write_all(data)?;
w.flush()?; // propagates errors
} // drop here: silent flush retry, any error is lost

Copy, rename, remove​

fs.copy(src, dst)?; // returns bytes copied
fs.rename(old_path, new_path)?; // atomic within fs
fs.remove_file(path)?;
fs.remove_dir(path)?; // fails if not empty
fs.remove_dir_all(path)?; // recursive

List a directory​

for entry in fs.read_dir(path)? {
let entry = entry?; // IoResult<DirEntry>
print(f"{entry.path} {entry.metadata()?.len()}B");
}

read_dir returns a streaming iterator; it does not materialise a list. For bulk listings, wrap in .collect<List<_>>().

Recursive walk​

for entry in fs.walk_dir(path)? {
let entry = entry?;
if entry.file_type()?.is_file() {
process_file(&entry.path).await?;
}
}

File metadata​

note
md.len() used to need Metadata in scope — it no longer does

This is fixed, and the note stays because the workaround it describes was on this page and readers may still be carrying it.

With only mount core.io.fs; — the mount this page opens with — md.len() did not reach Metadata.len. The receiver's type was not resolvable at the call site, so the call compiled to the builtin container-length opcode: it answered 1, the number of fields on Metadata ({ raw: FsMetadataRaw }), with no error at all.

Checked on a rebuilt compiler, both spellings, on the same file:

verum run bare.vr # mount core.io.fs; -> size=10
verum run named.vr # mount core.io.fs.{metadata, Metadata} -> size=10

and with the fixture rewritten to three bytes, both answer size=3, so the number follows the file rather than the shape of the record.

The regression is pinned by vcs/specs/L2-standard/stdlib/io/metadata/metadata_len_without_naming_the_type.vr, which asserts both spellings against a ten-byte file. Ten is chosen deliberately: a one-byte fixture would make the correct answer and the old wrong one identical, and the spec would pass in both worlds.

You may still prefer to name the type in the mount — it documents what the file uses — but nothing depends on it any more.

let md = fs.metadata(path)?;
// `modified_secs` / `accessed_secs` / `created_secs` answer seconds as
// an Int — there is no `modified()`. `permissions()` answers a
// `Permissions`, not a number, so ask it for its `mode()` before
// formatting it octal.
print(f"size={md.len()} modified={md.modified_secs()} mode={md.permissions().mode():o}");

let t = md.file_type();
if t.is_file() { ... }
if t.is_dir() { ... }
if t.is_symlink() { ... }
fs.symlink(target, link_path)?; // create

let metadata = fs.symlink_metadata(path)?; // does not follow
let target = fs.read_link(path)?; // → Path

Permissions​

// `Permissions` is built FROM a mode, not mutated into one: its surface
// is `from_mode`, `mode`, `readonly`, `set_readonly`. There is no
// `set_mode` on it, and no `fs.set_mode` helper either.
fs.set_permissions(path, Permissions.from_mode(0o644))?;

// Read the current mode, flip a bit, write it back:
let mode = fs.metadata(path)?.permissions().mode();
fs.set_permissions(path, Permissions.from_mode(mode | 0o111))?; // +x

Memory-mapped files​

Not available

There is no core.io.mmap. core/io/ is buffer, engine, file, fs, mod, path, process, protocols and stdio — the module this section used to tell you to mount has never existed, and neither have Mmap and MmapMut. The only mapping code in the tree is the SQLite VFS's MmapPolicy, which decides a size and does not map anything.

Until a mapping API lands, read large files through BufReader and a chunk loop. It costs the read calls that mapping would have saved, and it works today:

fn scan_chunks(path: &Path) -> IoResult<()> using [FileSystem] {
let file = File.open(path)?;
let mut reader = BufReader.with_capacity(64 * 1024, file);
let mut chunk: [Byte; 4096] = [0; 4096];
loop {
// `&mut chunk[..]`, not `&mut chunk` — the slice is what `read`
// takes, and the bare array coerces to an empty one.
let n = reader.read(&mut chunk[..])?;
if n == 0 { break; }
process(&chunk[..n]);
}
Result.Ok(())
}

Async I/O​

For high-throughput servers, use the _async variants:

The async surface is a SEPARATE TYPE — AsyncFile — not _async methods bolted onto File:

mount core.io.file.{AsyncFile};
mount core.io.buffer.{BufReader};

async fn count_errors_async(path: &Text) -> IoResult<Int>
using [FileSystem]
{
let file = AsyncFile.open(path).await?;
let mut reader = BufReader.new(file);
let mut count = 0;
while let Maybe.Some(line) = reader.next_line_async().await? {
if line.starts_with("ERROR") { count += 1; }
}
Result.Ok(count)
}

AsyncFile.open / .create / .open_with_options schedule the syscall on the IO pool; the instance methods .read, .write, .read_to_end, .read_to_string, .write_all, .flush, .sync_all and .seek are all async and yield when data is not ready. For a whole file in one call there are four free functions that skip the handle entirely:

public async fn read_to_string_async(path: &Text) -> IoResult<Text>;
public async fn read_async(path: &Text) -> IoResult<List<Byte>>;
public async fn write_async(path: &Text, contents: &Text) -> IoResult<()>;
public async fn write_bytes_async(path: &Text, contents: &[Byte]) -> IoResult<()>;

There is no File.open_async — the synchronous File has open / create / create_new / options and nothing async — and the async open takes a &Text, not a &Path.

Error handling​

Common IoError variants for file I/O:

VariantCause
NotFoundPath does not exist.
PermissionDeniedMissing read/write/execute bit.
AlreadyExistsFile.create with create_new(true) on existing path.
InvalidDataUTF-8 check failed in read_to_string.
IsADirectoryFile.open on a directory.
NotADirectoryDirectory syscall on a file.
StorageFullNo space left on device.
QuotaExceededUser quota.
InvalidFilenameIllegal byte sequence in the path.
InterruptedA signal interrupted the syscall.

IoError implements Display, so f"{e}" gives a readable message. See language/error-handling for the full ladder.

Testing with a virtual filesystem​

The MemoryFs mock replaces the real FileSystem context in tests:

let fs = MemoryFs.new()
.with_file("/etc/config.toml", b"key = 1")
.with_dir("/var/log");

provide FileSystem = fs in {
let text = fs.read_to_string(path#"/etc/config.toml")?;
assert_eq(text, "key = 1");
}

See also​

  • stdlib/io — Read, Write, BufRead, async file handles, mmap.
  • stdlib/text — Text.from_utf8, line iteration, encoding helpers.
  • stdlib/sys — paths, environment, file descriptors.
  • CLI tool tutorial — a project that puts file I/O together with configuration and reporting.