File
A file is where a program's data outlives the program. Writing one and reading it back takes only a handful of calls, but every one of them can fail for reasons outside the program: the disk is full, the name is taken, permission is refused, the file is not there. So every step returns a fallible, T ! IoError, and this lesson accounts for each one.
Main is fallible too: func Main() -> ! IoError | TextError. Its failure side is an error sum of two errors — IoError from the file calls and TextError from turning text into a path. A ? anywhere passes a failure on, and the program ends with exit status 1.
Opening a file
File::Open takes an allocator, a path and an OpenOptions saying what you mean to do:
var writing = File::Open(allocator, path, OpenOptions::Writing())?;
The options decide both what the handle may do and what happens to the file itself:
| Options | The handle may | If the file is missing | If it exists |
|---|---|---|---|
OpenOptions::Reading() | read | fails with NotFound | opened as it is |
OpenOptions::Writing() | write | created | emptied first |
OpenOptions::Appending() | write at the end | created | kept; writes go after it |
The open file is a File, and it must be var: writing, reading and closing all change it.
Writing, and closing
WriteAll(writing, "first line\nsecond line\n")?;
writing.Close()?;
File::Write is a single attempt: it may move fewer bytes than asked, and the count it returns says how many. Io::WriteAll keeps calling Write until every byte is out, which is almost always what you want.
Close is fallible as well. The system may have delayed reporting a failed write until now, so a close that is not checked can lose an error that happened earlier.
Reading until the end
File::Read fills as much of a buffer as it can in one attempt and returns how many bytes it put there. The lesson's buffer is only eight bytes, so the 23 bytes of the file take several reads:
var buffer: char8[8];
var reads = 0;
var total: uint = 0;
loop {
let filled = reading.Read(buffer[..8]) catch {
error if error.IsEnd() => break,
error => fail error
};
Print("{}", buffer[..filled]);
reads += 1;
total += filled;
}
The surprise is how the end arrives. It is not a count of zero: it comes on the failure channel, as an IoError whose IsEnd() is true. So the catch has two arms, separated by a guard: the end of the file breaks out of the loop, and any other error is a real failure, passed on with fail.
flowchart LR
r["reading.Read(buffer[..8])"] --> q{"what came back?"}
q -- "a count" --> use["print buffer[..filled],<br/>add it up, read again"]
use --> r
q -- "an IoError,<br/>IsEnd() true" --> done["break: the file is read"]
q -- "any other<br/>IoError" --> fail["fail error:<br/>Main ends with status 1"]Only the first filled bytes are new. The rest of the buffer still holds whatever the previous read left there, which is why the loop prints buffer[..filled], never the whole buffer.
| Read | Bytes | Buffer holds |
|---|---|---|
| 1 | 8 | first li |
| 2 | 8 | ne, a newline, secon |
| 3 | 7 | d line and a newline, plus a stale n |
| 4 | — | the end of the file: break |
Handling a failure instead of passing it on
Not every failure should end the program. After DeleteFile the file is gone, so opening it again must fail — and here that is the expected outcome. A match on the fallible takes it apart:
match File::Open(allocator, path, OpenOptions::Reading()) {
.Success(_) => PrintLine("still there?"),
.Failure(error) if error.kind == IoErrorKind::NotFound => PrintLine("deleted, as expected"),
.Failure(error) => fail error
}
error.kind is an IoErrorKind, an enum that names what went wrong in the same way on every platform: NotFound, PermissionDenied, AlreadyExists, StorageFull and more. The guard picks out the one failure this code expects; anything else is still passed on.
The program
The whole lesson is one package in the Examples repository. Its comments explain every step.
// Writing a text file and reading it back, with every failure accounted for.
//
// Each step can fail for reasons outside the program: the disk is full, the name is taken,
// permission is refused. So each one returns `T ! IoError`, and `Main` is fallible: `?` passes a
// failure on and the program ends with exit status 1. Its failure side also names `TextError`,
// which turning text into a path can produce.
//
// Two surprises are worth knowing before the code.
//
// - `File::Write` and `File::Read` are single attempts. Each may move fewer bytes than asked,
// and the count it returns says how many. `Io::WriteAll` keeps calling `Write` until every
// byte is out, and a reader loops in the same way.
// - The end of a file is not a count of zero. It arrives on the failure channel, as an
// `IoError` whose `IsEnd()` is true, so the read loop's `catch` treats it as "done" and any
// other error as a real failure.
//
// The file lives in this package's `Bin/` folder, and the program deletes it at the end.
import Allocator::{ Allocator, SystemAllocator };
import FileSystem::{ DeleteFile, File, OpenOptions };
import Io::{ IoError, IoErrorKind, Print, PrintLine, WriteAll };
import Path::{ OsString, Path };
import Text::TextError;
func Main() -> ! IoError | TextError {
var system = SystemAllocator();
let allocator: Allocator = system;
var holder = OsString::FromText(allocator, "Bin/notes.txt")?;
let path = Path::FromView(holder.View());
// `Writing` creates the file, or empties it if it already exists.
var writing = File::Open(allocator, path, OpenOptions::Writing())?;
WriteAll(writing, "first line\nsecond line\n")?;
// Closing can report a failure that was delayed until now, so it is fallible too.
writing.Close()?;
PrintLine("wrote {}", path);
// `Reading` requires the file to exist. A small buffer makes the loop take several reads.
var reading = File::Open(allocator, path, OpenOptions::Reading())?;
var buffer: char8[8];
var reads = 0;
var total: uint = 0;
loop {
let filled = reading.Read(buffer[..8]) catch {
error if error.IsEnd() => break,
error => fail error
};
// Only the first `filled` bytes are new. The rest of the buffer is left over.
Print("{}", buffer[..filled]);
reads += 1;
total += filled;
}
reading.Close()?;
PrintLine("read {} bytes in {} reads", total, reads);
DeleteFile(allocator, path)?;
// Handling a failure instead of passing it on: the file is gone, so opening it fails.
match File::Open(allocator, path, OpenOptions::Reading()) {
.Success(_) => PrintLine("still there?"),
.Failure(error) if error.kind == IoErrorKind::NotFound => PrintLine("deleted, as expected"),
.Failure(error) => fail error
}
}
Besides Io, its Rux.toml lists Allocator, FileSystem, Path and Text under [Dependencies].
Run it
cd Examples/Files/File
rux run
wrote Bin/notes.txt
first line
second line
read 23 bytes in 3 reads
deleted, as expected
Common mistakes
Close unchecked.writing.Close(); fails with error: fallible result of type '! IoError' is discarded. Close can report a failure that was delayed until then, so handle it like any other step.Replace the
catch with ? and the program compiles, prints the file — and then ends with exit status 1 and no message, because the end arrives as an IoError and ? passes it on. It never reaches DeleteFile, so Bin/notes.txt is left behind. Catch the end with IsEnd().Print("{}", buffer[..]) prints the stale bytes too: the output gains a stray n after second line, left over from the second read. Print buffer[..filled].let.let writing = File::Open(…)?; makes the later calls fail: error: argument 1 to 'WriteAll' cannot borrow immutable 'writing' as '&var Writer', and error: cannot call 'Close' on immutable 'writing'. An open file changes as you use it; declare it var.File::Open(allocator, "Bin/notes.txt", …) fails with error: argument 2 to 'File::Open' has type 'char8[..]', but parameter 'path' requires 'Path'. Convert the text with OsString::FromText and take a Path of it, as at the top of Main.Try it yourself
- Change the buffer to 4 bytes, and then to 64. How many reads does the file take each time?
- Open the file a second time with
OpenOptions::Appending(), add a third line, and read the whole file back. - Open a file that does not exist with
OpenOptions::Reading()and let?pass the failure on. Check the exit status withecho $?. - Count the lines as you read, by counting the newline characters in each
buffer[..filled].
Learn more
- Error sum, Catch and Guard — the tools this lesson combines
- Fallible main — what happens when
?reachesMain - Buffered I/O — fewer, larger reads and writes
- Atomic file — replacing a file so that no reader ever sees it half-written
loopandmatchin the Rux Reference