Files

Files beneath directory handles, and their typed terminal outcomes.

An app never names a file by an ambient path. It asks io.files() for a directory handle and names files relative to it:

init! = App.init(
    App.default.with_app_id("dev.example.notes"),
    |io| {
        saves = io.files().app_data!()?
        Ok({ saves: saves })
    },
)

update! = |model, input, _io| {
    if input.devices.key_pressed(KeyS) {
        saves = model.saves
        Task.spawn!(input, || Saved(saves.write_text!("slot1.json", encode(model))))
    }
    Ok(model)
}

Where a handle can come from:

  • app_data!, app_config!, app_cache!: the app's private storage under the user's home, created on first use. Needs App.Config.with_app_id.
  • beside_executable!: the directory the executable is in, read-only -- the app's own bundle.
  • working_directory! and working_directory_read!: the launch directory, when the app declares WorkingDirectory.
  • open_dir! and open_dir_read!: a directory a Directory, WorkingDirectory, or FilesAny declaration covers.
  • accept_drop! and from_arg!: a file or directory the user dropped on the window or named as an argument, as a Designated item.

A Dir can write; a ReadDir cannot, and Dir.read_only narrows one to the other. subdir narrows either to a directory beneath it. A handle is an ordinary value: keep it in the model, pass it to a helper, capture it in a task. Code that has the handle can access what is inside that directory, and nothing else.

A path given to a handle is plainly relative: no leading /, no drive, no backslash, and no empty, ., or .. component. Symbolic links beneath a handle are not followed, and an existing link where a write would land is not written through.

Two errors say why a target was refused, and they mean different things:

  • PathInvalid: the path's shape, or a link it meets, is refused. The rule is the same wherever the path would have led, so fix the path.
  • PermissionDenied: no grant covers the target. That is a directory no declaration covers, a stub handle, a drop that is not from this cycle, or a string that is not one of the app's arguments. Declare more, or ask the user to choose the item.

Every effect here waits except subdir, read_only, dir, accept_drop! and from_arg!, which open nothing. A waiting effect is legal in init!, where it blocks startup, and in tasks, where it parks the task; it is 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 : [PermissionDenied, PathInvalid, NotFound, ReadFailed, Busy, Unavailable, TooLarge, NotUtf8]

Why read_text! produced no UTF-8 string.

PathInvalid is a path that is not plainly relative, or that meets a symbolic link. PermissionDenied is a stub handle, which gives no access. 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 : [PermissionDenied, PathInvalid, 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 : [PermissionDenied, PathInvalid, 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, AccessRefused, PathInvalid, 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. AccessRefused 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. PathInvalid and PermissionDenied mean what they mean for a read, except that a link at the end of the path is described as Other rather than refused. 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, AccessRefused, PathInvalid, 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. AccessRefused is the operating system refusing the write, such as a read-only file or filesystem. PathInvalid is a path that is not plainly relative, one that meets a symbolic link, or an existing symbolic link at the path itself. PermissionDenied is a stub handle. WriteFailed is every other refusal the host cannot name more precisely.

OpenError : [PermissionDenied, PathInvalid, NotFound, NotADirectory, AccessRefused, OpenFailed, Unavailable]

Why a directory handle could not be opened.

PermissionDenied is a directory no declaration covers. PathInvalid is a declared path with a .. component, which only FilesAny can cover; only open_dir! and open_dir_read! return it. NotFound is a read-only directory that is not there -- a writable one is created -- and NotADirectory is a path that is there and is a file. AccessRefused is the operating system refusing to open it, and OpenFailed is every other refusal. Unavailable is the app shutting down while the open was parked.

PathInvalid : [PathInvalid]

Why a path given to subdir is not one a handle accepts: it is empty, absolute, or has a backslash, a drive, a NUL, or an empty, ., or .. component.

ReadDir

Files.ReadDir :: # (opaque)

A directory the app may read beneath, and nothing else.

Get one from io.files(), from Dir.read_only, or from subdir.

stub : ReadDir

Resource-free handle for pure tests. Every effect through it is PermissionDenied.

subdir : ReadDir, Str -> Try(ReadDir, [PathInvalid])

The directory path names beneath this one. Pure: nothing is opened, and a directory that is not there is found out on first use.

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

Read a bounded UTF-8 file into a Str: 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! : ReadDir, Str => Try(List(U8), ReadBytesError)

Read a bounded file as ordinary Roc bytes: at most 16 mebibytes, and a file past that is TooLarge.

Nothing is copied, so the list owns host-backed storage; at most 32 such allocations are live at once, and a read made while all 32 are held returns Busy without touching the disk. Retaining a sublist retains the whole allocation, 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! : ReadDir, Str => Try(List(Entry), ListError)

List one directory without walking its children; "" lists this handle's own directory. Entry order is the filesystem's, unsorted. A listing is bounded at 8192 entries and one mebibyte of encoded names, and a directory past either is TooLarge.

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

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

What one path is, how big it is, and when it last changed; "" is this handle's own directory. A symbolic link is reported as Other rather than followed.

Polling modified from a task, sleeping between stats, is how an app hot-reloads a shader, a level, or a dataset it did not write.

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

for_host : ReadDir -> { authority : Authority, root : Str, path : Str }

Internal bridge for platform operations that open things beneath a handle, such as an asset store or a database.

Dir

Files.Dir :: # (opaque)

A directory the app may read and write beneath, and nothing else.

Get one from io.files() or from subdir.

stub : Dir

Resource-free handle for pure tests. Every effect through it is PermissionDenied.

read_only : Dir -> ReadDir

The same directory, without the right to write.

subdir : Dir, Str -> Try(Dir, [PathInvalid])

The directory path names beneath this one. Pure, as for ReadDir; a missing directory is created by the first write into it.

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

As ReadDir.read_text!.

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

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

As ReadDir.read_bytes!.

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

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

As ReadDir.list!.

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

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

As ReadDir.metadata!.

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

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

Replace a file's contents with a Str, creating it and any missing directories on the way. The write replaces the whole file: there is no append, and no partial write is reported as success. An existing symbolic link at the path is refused rather than written through.

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

write_bytes! : Dir, Str, List(U8) => Try({}, WriteError)

Replace a file's contents with ordinary Roc bytes, exactly as write_text! does with a string.

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

for_host : Dir -> { authority : Authority, root : Str, path : Str }

Internal bridge for platform operations that open things beneath a handle, such as a database.

Designated

Files.Designated :: # (opaque)

One file or directory the user chose for the app: dropped on its window, or named as an argument when it was launched. The user's choice counts as permission, so none is declared, and the handle gives access to exactly that item and nothing beside it.

Read it as a file with read_text!, read_bytes!, and metadata!, or, when it is a directory, open what is inside it with dir.

stub : Designated

Resource-free handle for pure tests. Every effect through it is PermissionDenied.

path : Designated -> Str

The absolute path that was designated, for showing to the user.

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

Read the designated file into a Str, as ReadDir.read_text! does.

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

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

Read the designated file as bytes, as ReadDir.read_bytes! does.

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

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

What the designated item is, how big it is, and when it last changed.

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

dir : Designated -> ReadDir

The designated item as a directory, when it is one. Pure; an item that is not a directory is found out on first use.

Access

Files.Access :: # (opaque)

Opaque filesystem authority supplied by App.Io: where directory handles come from.

for_host : Authority -> Access

Private platform construction; no application can manufacture the argument.

app_data! : Access => Try(Dir, [PermissionDenied, PathInvalid, NotFound, NotADirectory, AccessRefused, OpenFailed, Unavailable])

The app's private data directory, created on first use: saves, databases, anything the app keeps for itself. Needs App.Config.with_app_id; without one the app stops, naming it.

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

app_config! : Access => Try(Dir, [PermissionDenied, PathInvalid, NotFound, NotADirectory, AccessRefused, OpenFailed, Unavailable])

The app's private configuration directory, created on first use.

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

app_cache! : Access => Try(Dir, [PermissionDenied, PathInvalid, NotFound, NotADirectory, AccessRefused, OpenFailed, Unavailable])

The app's private cache directory, created on first use: files it can recreate if the user clears it.

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

beside_executable! : Access => Try(ReadDir, [PermissionDenied, PathInvalid, NotFound, NotADirectory, AccessRefused, OpenFailed, Unavailable])

The directory the executable is in, read-only: the app's own bundle.

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

working_directory! : Access => Try(Dir, [PermissionDenied, PathInvalid, NotFound, NotADirectory, AccessRefused, OpenFailed, Unavailable])

The launch directory, writable. Needs WorkingDirectory(ReadWrite).

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

working_directory_read! : Access => Try(ReadDir, [PermissionDenied, PathInvalid, NotFound, NotADirectory, AccessRefused, OpenFailed, Unavailable])

The launch directory, read-only. Needs WorkingDirectory in either mode.

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

open_dir! : Access, Str => Try(Dir, [PermissionDenied, PathInvalid, NotFound, NotADirectory, AccessRefused, OpenFailed, Unavailable])

A declared directory, writable. A directory that is not there is created, with any missing parents, so the first run of an app finds an empty directory rather than NotFound.

An absolute path names the directory directly; a relative one is opened beneath the working directory. Whether a declaration covers the path is decided from its text, not by asking the filesystem:

  • Directory(dir, ReadWrite) covers dir and every path beneath it written the same way: an absolute declaration covers absolute paths, a relative one covers relative paths.
  • WorkingDirectory(ReadWrite) covers every relative path.
  • FilesAny(ReadWrite) covers every path.

A path no declaration covers is PermissionDenied. A path with a .. component is PathInvalid unless FilesAny covers it, because its text alone does not say where it leads. The declared directory is taken as named, links and all; past it, the path is opened one component at a time, and a symbolic link on the way is PathInvalid, so a link inside a declared directory cannot lead the handle outside it. Under FilesAny, links are followed. Links beneath the handle never are.

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

accept_drop! : Access, Str => Try(Designated, [PermissionDenied])

Accept a file or directory the user dropped on the window, from the update! whose input.dropped delivered it.

update! = |model, input, io| {
    for drop in input.dropped {
        match io.files().accept_drop!(drop.path) {
            Ok(item) => Task.spawn!(input, || Opened(item.path(), item.read_bytes!()))
            Err(PermissionDenied) => {}
        }
    }
    Ok(model)
}

Only a path the host delivered this cycle is accepted; a string made up, or kept from an earlier cycle, is PermissionDenied. Nothing is opened, so it is legal wherever a drop can be seen.

Legal in init!, update!, and tasks; refused in render!.

from_arg! : Access, Str => Try(Designated, [PermissionDenied])

Accept a file or directory named as an application argument, such as my-tool data.csv. The operator naming it is the grant.

Only a string byte-identical to one of the app's arguments (what io.args!() returns in init!) is accepted, and the item is read-only. Anything else is PermissionDenied: the operator did not name it. A relative argument names an item beneath the directory the app was launched in, fixed when it is accepted. Legal in init!, update!, and tasks; refused in render!.

open_dir_read! : Access, Str => Try(ReadDir, [PermissionDenied, PathInvalid, NotFound, NotADirectory, AccessRefused, OpenFailed, Unavailable])

A declared directory, read-only, covered by a declaration in either mode. Coverage is decided as for open_dir!, but nothing is created: a directory that is not there is NotFound.

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