Files

Filesystem reads and writes, and their typed terminal outcomes.

These effects wait. Every one of them is legal in init!, where it blocks startup until the answer is in -- which is what loading assets wants -- and in tasks, where it parks the task while the frame loop keeps drawing. They are refused in update! and render!, with a message naming the effect and the fix.

update! = |model, input| {
    if input.devices.key_pressed(KeyEnter) {
        Task.spawn!(input, || SaveLoaded(Files.read_text!("save.json")))
    }
    Ok(model)
}

Because a task is ordinary straight-line code, a multi-step load is a function rather than a state machine spread over Msg and update!:

load_level! : Str => Try(Msg, [LevelFailed])
load_level! = |dir| {
    manifest = Files.read_text!("${dir}/level.json") ? |_e| LevelFailed
    tiles = Files.read_bytes!("${dir}/tiles.bin") ? |_e| LevelFailed
    Ok(LevelLoaded({ manifest, tiles }))
}

? gives up on the first failure, so the function answers with a Try and the task closure turns that into the one message it owes: Task.spawn!(input, || match load_level!(dir) { Ok(msg) => msg, Err(_) => LevelFailed }).

Paths are used as the app gives them, resolved against the process working directory, and nothing here is sandboxed. Capture is the one part of this platform with an output root, and it confines captures only; see write_text!.

read_text! : Str => Try(Str, ReadTextError)

Read a bounded UTF-8 file into a Str.

The whole file is copied into the string, so this is capped well below what read_bytes! will read: at most 64 kibibytes, and a file past that is TooLarge rather than truncated. One that is not valid UTF-8 is NotUtf8 rather than an invalid Str.

Legal in init!, where it blocks startup, and in tasks, where it parks the task; refused in update! and render!.

read_bytes! : Str => Try(List(U8), ReadBytesError)

Read a bounded file as ordinary Roc bytes.

At most 16 mebibytes, and a file past that is TooLarge. The ceiling is far above read_text!'s because nothing is copied: the buffer the read filled is the buffer Roc gets.

That is also why there is a second bound. The delivered list owns host-backed storage through Roc ARC, and at most 32 such allocations are live at once; a read made while all 32 are held answers Busy without touching the disk. Retaining a sublist retains the complete source allocation and so holds a slot, which List.release_excess_capacity releases by copying out the part worth keeping.

Legal in init!, where it blocks startup, and in tasks, where it parks the task; refused in update! and render!.

list! : Str => Try(List(Entry), ListError)

List one directory without recursively walking its children.

Entry order is the filesystem's observed order and is not sorted. Recursion is the app's to drive: only the app knows which subtrees are worth descending into, and a host-side walk would be one unbounded wait.

A listing is bounded at 8192 entries and at one mebibyte of encoded names, whichever binds first; a directory past either is TooLarge rather than a partial listing. It is delivered through the same 32 file-byte slots a byte read uses, so it can answer Busy for the same reason.

Legal in init!, where it blocks startup, and in tasks, where it parks the task; refused in update! and render!.

metadata! : Str => Try(Metadata, MetadataError)

What one path is, how big it is, and when it last changed.

Symbolic links are followed, so a link to a file is File and a link to nothing is NotFound. That differs from list!, which reports the kind the directory itself records and so calls a symbolic link Other; metadata! answers about the thing at the end of the path, which is what an app deciding whether to read it wants to know.

Polling modified is how an app hot-reloads a shader, a level, or a dataset it did not write. Do it inside a task, and sleep between stats, so the watching costs a parked task rather than a stat every frame:

watch! : Str, Time.Timestamp => Msg
watch! = |path, seen| {
    var $outcome = Unchanged
    while $outcome == Unchanged {
        Task.sleep!(250)
        $outcome = match Files.metadata!(path) {
            Ok(meta) if meta.modified != seen => Modified(meta.modified)
            Ok(_) => Unchanged
            Err(NotFound) => Unchanged
            Err(other) => Stopped(other)
        }
    }
    match $outcome {
        Modified(at) => Changed(path, at)
        Stopped(err) => WatchFailed(err)
        Unchanged => crash("watch!: the loop only ends once the path changed or the stat failed")
    }
}

The loop carries a sentinel tag rather than the message itself, so its while condition can compare it, and the match after the loop turns that sentinel into the one message the task owes.

A loop rather than a recursive call, because a task runs on a fixed-size coroutine stack. NotFound keeps waiting rather than giving up: an editor saving a file often replaces it, so the path can be missing for a moment. update! spawns the watcher again when it handles Changed, and each live watcher holds one of the host's thirty-two task slots for as long as it watches.

Legal in init!, where it blocks startup, and in tasks, where it parks the task; refused in update! and render!.

write_text! : Str, Str => Try({  }, WriteError)

Replace a file's contents with a Str, creating it if it is not there.

The write replaces the whole file: there is no append, and no partial write is reported as success. Missing parent directories are created, the same as for every file the host writes itself, so an app's first write_text!("saves/slot1.json", ...) does not need a separate step to make saves/.

The path is used as the app gave it, resolved against the process working directory, exactly as read_text! resolves one. Files is not sandboxed in either direction: an app that can read /etc/hosts can write /tmp/out.txt. The one output root this platform enforces belongs to Capture, whose paths are computed by recording machinery rather than written out by the app, and it confines captures only.

Legal in init!, where it blocks startup, and in tasks, where it parks the task; refused in update! and render!.

update! = |model, input| {
    if input.devices.key_pressed(KeyS) {
        Task.spawn!(
            input,
            || match Files.write_text!("saves/slot1.json", encode(model)) {
                Ok({}) => Saved
                Err(err) => SaveFailed(err)
            },
        )
    }
    Ok(model)
}
write_bytes! : Str, List(U8) => Try({  }, WriteError)

Replace a file's contents with ordinary Roc bytes.

The same path rules, the same whole-file replacement, and the same parent-directory creation as write_text!; only the payload differs. Nothing about the bytes is inspected, so this is the call for a PNG, a save blob, or anything else that is not text.

Legal in init!, where it blocks startup, and in tasks, where it parks the task; refused in update! and render!.

Entry : { name : Str, kind : EntryKind }

One entry returned by list!.

EntryKind : [File, Dir, Other]

The filesystem kind relevant to a non-recursive directory walk.

ReadTextError : [NotFound, ReadFailed, Busy, Unavailable, TooLarge, NotUtf8]

Why read_text! produced no UTF-8 string.

NotFound is no file at that path. TooLarge is a file past read_text!'s 64 kibibyte ceiling, which is a refusal rather than a failure: nothing went wrong and the file is there. NotUtf8 is a file that was read and is not valid UTF-8, reported rather than delivered as an invalid Str. Busy is the host's thirty-two file-delivery slots all being held by byte lists an app has retained; nothing was read, and the same call later can succeed. Unavailable is the app shutting down while the read was parked.

ReadFailed is every other refusal, and a permission the process does not have is one of them: the host does not distinguish it from a read that failed for any other reason. metadata! does, so a path that may be unreadable can be stat'd first to tell the two apart.

ReadBytesError : [NotFound, ReadFailed, Busy, Unavailable, TooLarge]

Why read_bytes! produced no byte list.

The same tags as ReadTextError minus NotUtf8, since nothing about the bytes is inspected, and with a much larger ceiling: TooLarge here is a file past 16 mebibytes. ReadFailed covers a permission denial in exactly the same way.

ListError : [NotFound, NotADirectory, ReadFailed, Busy, Unavailable, TooLarge]

Why list! produced no directory entries.

NotADirectory is a path that is there and is a file. TooLarge is a directory whose listing would exceed 8192 entries or one mebibyte of encoded names, whichever binds first. The rest mean what they mean for a read, ReadFailed included.

Metadata : { kind : EntryKind, size_bytes : U64, modified : Timestamp }

What one path is, how big it is, and when it last changed.

size_bytes is the file's length; for a directory it is whatever the filesystem reports for the directory itself, which is not the size of what is inside it. modified is wall-clock time, so it is comparable with Time.now! and with a modified this app recorded earlier, and it is not comparable with input.time.

MetadataError : [NotFound, PermissionDenied, ReadFailed, Unavailable]

Why metadata! could not describe the path.

NotFound is nothing at that path, including a path a component of which is a file rather than a directory, and a name this filesystem cannot represent. PermissionDenied is a directory on the way to the path this process may not look inside -- the one failure a stat can name that a read cannot. Unavailable is the app shutting down while the stat was parked, and ReadFailed is every other refusal.

There is no Busy: a stat holds no host-owned payload, so there is no delivery slot for it to run out of.

WriteError : [NotFound, PermissionDenied, NoSpace, WriteFailed, Unavailable]

Why a write did not leave the file on disk. This is Files' own WriteError; Stdout and Stderr declare a different one under the same name, for the different things a stream write can refuse.

NotFound means a component of the path is a file rather than a directory, or names something that cannot be created; the missing directories a write would otherwise trip over are created for it. NoSpace is the filesystem being full or over quota, and WriteFailed is every other refusal the host cannot name more precisely.