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_untilon the first iteration, dereferencing the element VALUE where it expects a reference. Line-oriented reading is interpreter-only until that closes.read_to_stringbelow is unaffected at both tiers, and so isText.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
md.len() used to need Metadata in scope — it no longer doesThis 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() { ... }
Symbolic links
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
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:
| Variant | Cause |
|---|---|
NotFound | Path does not exist. |
PermissionDenied | Missing read/write/execute bit. |
AlreadyExists | File.create with create_new(true) on existing path. |
InvalidData | UTF-8 check failed in read_to_string. |
IsADirectory | File.open on a directory. |
NotADirectory | Directory syscall on a file. |
StorageFull | No space left on device. |
QuotaExceeded | User quota. |
InvalidFilename | Illegal byte sequence in the path. |
Interrupted | A 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.