Skip to content

File Operations

← Back to Standard Library

File system operations for reading, writing, and managing files.

Import

<io/files> holds the path utilities -- exists(), remove(), read_dir() -- and the descriptor layer, and it brings FileError, the enum they answer, by re-exporting <io/error>. File, open() and the three console handles live in <io/fs>, beside stat() and walk(), so most programs want both:

use <io/files>
use <io/fs>

Overview

A File OWNS its descriptor. It moves to exactly one owner and closes itself when that owner leaves scope, so an explicit close() is only needed where the failure has to be seen -- a destructor cannot answer a Result. The compiler enforces the one-owner rule; nothing here has to be remembered.

use <io/fs>

fn log_it() ~ | IoError:
    let File f = open("out.log", FileMode.Append())??
    f.writeln("Mostly Harmless")??
    return Result.Ok(~)
    # f drops here. The descriptor closes.

Three things follow from that, and each surprises somebody:

  • A File cannot be copied. .clone() is CE2431: a field-by-field copy would duplicate the descriptor number and leave two owners that both close it.
  • close() CONSUMES the handle. A read after a close is CE2435 while compiling, not EBADF at run time, and the scope exit that follows has nothing left to close. A bare match binding is a read-only view, so Result.Ok(f) -> f.close() is CE2411: write Result.Ok(nom f) over a scrutinee the match owns, or -- better -- delete the close() and let the drop do it. A handle held in a struct FIELD cannot be closed explicitly at all, for the same reason.
  • Reads and writes are UNBUFFERED. Each one is a system call. A handle that buffered would have to flush at a point nobody wrote, and a destructor cannot report the failure of that flush -- so buffering is a separate type that a caller opts into, BufReader and BufWriter.

The contract methods -- read, write, flush, seek, and the conveniences over them (read_all, readch, writeln, tell) -- take poke self: the receiver every Reader and Writer declares, so that a BUFFERED handle can implement them. A file's own position lives in the KERNEL, so the mode costs it nothing, but it does mean the handle you write through must be writable storage: a let local, a poke parameter or a nom/poke match binding -- never a bare borrow. close() takes the handle.

stdin, stdout and stderr are File values too -- see Console I/O.

Opening Files

open

Open a file with a specific mode.

use <io/fs>

fn open(string path, FileMode mode) File | IoError

Parameters: - path - File path (relative or absolute) - mode - File access mode

Returns: - Result.Ok(file) - Successfully opened file - Result.Err(error) - Error occurred (see Error Handling section)

File Modes:

  • FileMode.Read() - Open for reading only (file must exist)
  • FileMode.Write() - Open for writing only (creates file or truncates existing)
  • FileMode.Append() - Open for appending (creates file if doesn't exist)

Example:

use <io/files>
use <io/fs>

fn main() i32:
    match open("data.txt", FileMode.Read()):
        Result.Ok(nom f) ->
            println("open: {f.is_open()}")
            # f drops at the end of the arm, and the descriptor closes.
        Result.Err(_) ->
            println("Failed to open file")

    return Result.Ok(0)

With error propagation:

use <io/files>
use <io/fs>

fn read_config() string | IoError:
    let File f = open("config.txt", FileMode.Read())??
    return Result.Ok(f.read_all()??)
    # f drops at the return, and the descriptor closes.

fn main() i32:
    match read_config():
        Result.Ok(config) ->
            println("Config: {config}")
        Result.Err(_) ->
            println("Failed to read config")

    return Result.Ok(0)

File Methods

The whole surface, and where each method comes from. A method a contract provides is callable on a TcpStream too, which is what <io/contracts> is for.

method signature provider
read (poke self, i32 max) string \| IoError -- one read, as text Reader
read_bytes (poke self, i32 max) u8[] \| IoError -- one read, as bytes Reader
write (poke self, string data) ~ \| IoError -- every byte, or an error Writer
write_bytes (poke self, u8[] data) ~ \| IoError Writer
flush (poke self) ~ \| IoError -- a successful no-op on a descriptor Writer
seek (poke self, i64 offset, SeekFrom origin) i64 \| IoError -- answers the NEW position Seek
read_all (poke self) string \| IoError -- the whole file, from the current position File
readln () Maybe@(string) \| IoError -- one line, newline stripped; None at the end File
readch () string \| IoError -- one byte, as text File
writeln (string data) ~ \| IoError File
tell () i64 \| IoError File
read_at (i64 offset, i32 count) u8[] \| IoError -- one read at an offset; the position does not move File
write_at (i64 offset, u8[] data) i32 \| IoError -- one write at an offset, answers the count; the position does not move File
share () File \| IoError -- dup(2): a second OWNER over the SAME open file description, so the offset is SHARED File
is_open () bool -- false once close() has run File
is_terminal () bool -- true only for a terminal File
close (nom self) ~ \| IoError -- CONSUMES the handle File

A File closes itself when its owner leaves scope, so close() is only needed where the failure has to be SEEN. Every method is a plain borrow except close(), which CONSUMES the handle: a file's position lives in the kernel, not in the struct, so a read and a write need no mutable receiver.

A File keeps no line loop. There is no File.lines(): an unbuffered handle yielding lines is one system call per line, which is the cost the buffer exists to remove. readln() is the one-line unbuffered read and can serve in a pinch; BufReader.lines() is what a loop wants.

read and read_all

read(max) is ONE read and answers what arrived; read_all() loops until the end and answers the rest of the file from the current position.

fn File.read(i32 max) string | IoError
fn File.read_all() string | IoError

read()'s bound counts BYTES, so a multi-byte character can be split across two calls -- a caller that must not cut one reads bytes with read_bytes(), accumulates them, and converts once. An EMPTY answer is the end of input -- a SHORT answer is not, because a pipe hands over whatever has arrived so far. read_all() holds the whole answer in memory at once; a large file wants read() in a loop, or BufReader.

Example: the whole file, in a helper that carries the channel.

use <io/fs>

fn contents(string path) string | IoError:
    let File f = open(path, FileMode.Read())??
    return Result.Ok(f.read_all()??)

fn main() i32:
    match contents("data.txt"):
        Result.Ok(text) -> println("Content: {text}")
        Result.Err(_) -> println("Failed to read file")
    return Result.Ok(0)

Processing file content:

use <io/fs>
use <collections/strings>

fn report(string path) ~ | IoError:
    let File f = open(path, FileMode.Read())??
    let string content = f.read_all()??
    let string[] lines = content.split("\n")

    foreach(line in lines.iter()):
        if (not line.is_empty()):
            println("Line: {line}")

    return Result.Ok(~)

fn main() i32:
    match report("numbers.txt"):
        Result.Ok(_) -> println("read")
        Result.Err(_) -> println("failed")
    return Result.Ok(0)

readln

Read one line from the file, without its newline.

fn File.readln() Maybe@(string) | IoError

Returns: the line, or Maybe.None at end of file. A blank line is Maybe.Some(""), so a caller can tell a blank line from the end of the file. This is the UNBUFFERED read: one system call per line at best. Reading a whole file line by line wants BufReader from <io/buf>.

Example:

use <io/fs>

fn first_two(string path) ~ | IoError:
    let File f = open(path, FileMode.Read())??
    match f.readln()??:
        Maybe.Some(line) -> println("First: {line}")
        Maybe.None -> println("empty file")
    match f.readln()??:
        Maybe.Some(line) -> println("Second: {line}")
        Maybe.None -> println("one line only")
    return Result.Ok(~)

Line-by-line processing: the loop ends on Maybe.None and on nothing else, so a blank line in the middle of the file does not truncate it. Note that this is the UNBUFFERED loop, at one system call per line: foreach(line?? in r.lines()) over a BufReader is what a whole file wants, and it is shorter besides.

use <io/fs>

fn count_lines(string path) i32 | IoError:
    let File f = open(path, FileMode.Read())??
    let i32 lines = 0
    let bool done = false
    while (not done):
        match f.readln()??:
            Maybe.Some(line) ->
                lines := lines + 1
                println("{lines}: {line}")
            Maybe.None ->
                done := true
    return Result.Ok(lines)

write and writeln

fn File.write(string data) ~ | IoError
fn File.writeln(string data) ~ | IoError
fn File.write_bytes(u8[] data) ~ | IoError

A write answers ~ and never a count: the primitive underneath LOOPS past a short write, so every byte has gone or the call is an error. That is why there is no partial-write case to handle, and why a discarded write Result is CW2001 at every bare-statement call site.

Example:

use <io/fs>

fn write_report(string path, i32 count) ~ | IoError:
    let File f = open(path, FileMode.Write())??
    f.writeln("Report")??
    f.writeln("======")??
    f.writeln("Items processed: {count}")??
    return Result.Ok(~)
    # f drops here, and the descriptor closes.

fn main() i32:
    match write_report("report.txt", 42):
        Result.Ok(_) -> println("written")
        Result.Err(_) -> println("failed")
    return Result.Ok(0)

Appending: the mode is the only difference. FileMode.Append() puts every write at the end of the file, so two processes appending do not overwrite each other's lines.

use <io/fs>

fn log_line(string message) ~ | IoError:
    let File f = open("log.txt", FileMode.Append())??
    f.writeln(message)??
    return Result.Ok(~)

fn main() i32:
    match log_line("Mostly Harmless"):
        Result.Ok(_) -> println("logged")
        Result.Err(_) -> println("failed")
    return Result.Ok(0)

flush

fn File.flush() ~ | IoError

It does nothing, successfully. A descriptor is not buffered: by the time write() returns, the bytes are the kernel's. The method is on the Writer contract so that a function written against Writer keeps compiling when the handle it is given is swapped for a BufWriter, where the call does real work.

Getting the bytes onto the DISK is fsync(), a much stronger promise, and not what flush() has ever meant.

close

Close the file, and CONSUME the handle.

fn File.close(nom self) ~ | IoError

The call takes the handle, so the binding is spent: a read after a close is CE2435 while compiling, and the scope exit that follows has nothing to close. Call it only where the failure has to be SEEN -- an owned handle closes itself on drop, and a destructor cannot answer a Result. The close itself can fail: a write the file system had not finished is reported here and nowhere else.

Example:

use <io/fs>

fn show(string path) ~ | IoError:
    let File f = open(path, FileMode.Read())??
    let string content = f.read_all()??
    f.close()??
    println(content)
    return Result.Ok(~)

A handle in a struct field cannot be closed this way. A field read is a borrow, and consuming one is CE2411. Let the struct's own drop close it, or take the handle out of the wrapper that holds it with into_inner().

read_at and write_at

fn File.read_at(i64 offset, i32 count) u8[] | IoError
fn File.write_at(i64 offset, u8[] data) i32 | IoError

The offset is an ARGUMENT, so neither call moves the file position: a read() or a tell() afterwards finds it where it was. That is what makes the pair the answer for concurrent reads of one file -- nothing is shared between two callers, so nothing can race. Every language that supports concurrent file I/O converged on it, and none needed a new kind of type: the offset stops being shared state the moment it becomes an argument.

read_at is one pread(2) and answers what was there, which may be fewer bytes than asked for and is empty past the end of the file. write_at is one pwrite(2): the bytes it covers are replaced, the rest of the file is left alone, and it answers the count it took, which may be fewer than offered -- the loop is the caller's, exactly as it is for a socket's send(). A pipe has no offset, and both answer an error on one.

use <io/fs>

fn magic(string path) string | IoError:
    let File f = open(path, FileMode.Read())??
    let u8[] head = f.read_at(0 as i64, 4)??
    return Result.Ok(head.to_string())

fn main() i32:
    match magic("archive.bin"):
        Result.Ok(m) -> println("starts with {m}")
        Result.Err(_) -> println("no archive")
    return Result.Ok(0)

share

fn File.share() File | IoError

A second handle over the SAME open file description: dup(2). The answer is an independent descriptor the program OWNS, so it closes on drop -- even when the receiver is stdout, which does not -- and closing either handle leaves the other open. The receiver is a plain borrow, so let File twin = f.share()?? leaves f usable, and twin.close()?? spends only twin.

The offset is shared. It is part of the open file description, so a read through one handle moves the other. That makes share() the shared-listener pattern -- several workers taking turns on one descriptor -- and NOT the answer for concurrent reads of one file. read_at() and write_at() are that answer, because their offset is an argument.

A handle has no .clone() (CE2431): a copy verb would hide the second descriptor. share() is the operation that means one, and its name says so.

use <io/fs>

fn main() i32:
    match stdout.share():
        Result.Ok(nom out) -> out.writeln("Mostly Harmless")
        Result.Err(_) -> println("no second handle")
    return Result.Ok(0)

Error Handling

Which channel a call answers

Two enums, and the line between them is what the call DOES rather than which module it is in.

the call channel
open(), close(), and every read, write and seek on a File IoError
the path utilities -- exists, remove, rename, stat, mkdir_all, read_dir FileError
the fd_* descriptor primitives FileError

A read, a write and a seek answer IoError because those are the Reader / Writer / Seek contract methods (<io/contracts>), and a perk contract carries one signature. A TcpStream's read answers the same IoError, which is what lets one generic serve both.

open() and close() answer IoError too, so a function that opens a file and then reads it carries ONE channel from end to end and needs no conversion in the middle.

IoError

enum IoError:
    NotFound()          # ENOENT - the path does not exist
    PermissionDenied()  # EACCES, EPERM - insufficient permissions
    AlreadyExists()     # EEXIST - the path is already there
    IsDirectory()       # EISDIR - the path is a directory
    ConnectionReset()   # ECONNRESET, ECONNABORTED
    TimedOut()          # ETIMEDOUT
    Closed()            # EPIPE, ENOTCONN, EBADF
    Interrupted()       # EINTR
    WouldBlock()        # EAGAIN, EWOULDBLOCK
    DiskFull()          # ENOSPC - no space left on device
    TooManyOpen()       # EMFILE, ENFILE - too many open files
    InvalidInput()      # EINVAL, ENAMETOOLONG
    Os(i32 errno)       # the raw errno, for a failure with no variant of its own
    Other()             # anything else

Os(i32) is how detail survives without a global last_errno(), which would not be thread-safe. Match it when you need the number:

use <io/fs>

fn config() string | IoError:
    let File f = open("config.txt", FileMode.Read())??
    return Result.Ok(f.read_all()??)

fn main() i32:
    match config():
        Result.Ok(text) -> println(text)
        Result.Err(IoError.NotFound) -> println("no such file")
        Result.Err(IoError.Os(code)) -> println("errno {code}")
        Result.Err(_) -> println("could not read it")
    return Result.Ok(0)

FileError

The path utilities and the descriptor primitives keep their own enum:

enum FileError:
    NotFound()          # ENOENT - file does not exist
    PermissionDenied()  # EACCES, EPERM - insufficient permissions
    AlreadyExists()     # EEXIST - file already exists
    IsDirectory()       # EISDIR - path refers to a directory
    DiskFull()          # ENOSPC - no space left on device
    TooManyOpen()       # EMFILE, ENFILE - too many open files
    InvalidPath()       # ENAMETOOLONG - invalid path or filename
    IOError()           # EIO - generic I/O error
    Other()             # any other error

<io/fs> converts one into the other at its own boundary, with extend FileError to_io() IoError, so a caller never writes the conversion.

Error Patterns

Pattern matching all errors

use <io/files>
use <io/fs>

fn read_config() string | IoError:
    let File f = open("config.txt", FileMode.Read())??
    return Result.Ok(f.read_all()??)

fn main() i32:
    match read_config():
        Result.Ok(data) ->
            println(data)
        Result.Err(IoError.NotFound()) ->
            println("File not found")
        Result.Err(IoError.PermissionDenied()) ->
            println("Permission denied")
        Result.Err(_) ->
            println("Other error")

    return Result.Ok(0)

Nested error handling

use <io/files>
use <io/fs>

fn duplicate(string src, string dst) ~ | IoError:
    let File input = open(src, FileMode.Read())??
    let File output = open(dst, FileMode.Write())??
    output.write(input.read_all()??)??
    return Result.Ok(~)
    # Both handles drop here, in reverse declaration order: output, then input.

fn main() i32:
    match duplicate("data.txt", "output.txt"):
        Result.Ok(_) ->
            println("File copied")
        Result.Err(IoError.NotFound()) ->
            println("Input file not found")
        Result.Err(_) ->
            println("Failed to copy")

    return Result.Ok(0)

Using error propagation

use <io/files>
use <io/fs>

fn copy_file(string src, string dst) ~ | IoError:
    let File input = open(src, FileMode.Read())??
    let string content = input.read_all()??

    let File output = open(dst, FileMode.Write())??
    output.write(content)??
    output.close()??          # the close is checked, so a failed flush to disk is seen

    return Result.Ok(~)

fn main() i32:
    match copy_file("input.txt", "output.txt"):
        Result.Ok(_) ->
            println("Copy successful")
        Result.Err(_) ->
            println("Copy failed")

    return Result.Ok(0)

File Utility Functions

remove

Delete a file from the filesystem.

fn remove(string path) -> Result@(i32, FileError)

Parameters: - path - Path to the file to delete

Returns: - Result.Ok(0) - File successfully deleted - Result.Err() - Failed to delete file (doesn't exist, permission denied, etc.)

Example:

use <io/files>
use <io/fs>

fn main() i32:
    match remove("/tmp/old_file.txt"):
        Result.Ok(_) ->
            println("File deleted")
        Result.Err(_) ->
            println("Failed to delete file")

    return Result.Ok(0)

Important: Only works on files, not directories. Use rmdir() for directories.

rename

Rename or move a file or directory.

fn rename(string old_path, string new_path) -> Result@(i32, FileError)

Parameters: - old_path - Current path - new_path - New path

Returns: - Result.Ok(0) - Successfully renamed/moved - Result.Err() - Failed (source doesn't exist, permission denied, etc.)

Example:

use <io/files>
use <io/fs>

fn main() i32:
    match rename("/tmp/old.txt", "/tmp/new.txt"):
        Result.Ok(_) ->
            println("File renamed")
        Result.Err(_) ->
            println("Failed to rename")

    return Result.Ok(0)

Note: Atomically replaces destination if it exists.

mtime / ctime

The modification and status-change times of a path, as unix seconds.

fn mtime(string path) -> Result@(i64, FileError)
fn ctime(string path) -> Result@(i64, FileError)

Example:

use <io/files>

fn main() i32:
    match mtime("build/output"):
        Result.Ok(t) -> println("last built at {t}")
        Result.Err(_) -> println("never built")

    return Result.Ok(0)

Note: ctime is the inode status-change time, not the creation time. A chmod moves it; a content write moves both.

mode

The raw st_mode of a path: the file-type bits plus the permission bits.

fn mode(string path) -> Result@(i32, FileError)

Example:

use <io/files>

fn main() i32:
    match mode("script.sh"):
        Result.Ok(m) ->
            println("permissions: {m & 0o777}")
        Result.Err(_) -> println("no such file")

    return Result.Ok(0)

Note: mask with 0o777 for the permission bits, with 0o170000 for the file-type bits. is_file/is_dir/is_symlink answer the type question directly.

Ask whether the path itself is a symbolic link. This is the one query that does NOT follow the link (lstat); is_file and is_dir answer for the target.

fn is_symlink(string path) -> Result@(bool, FileError)

Example:

use <io/files>

fn main() i32:
    match is_symlink("/usr/local/bin/tool"):
        Result.Ok(link) ->
            if (link):
                println("a link")
            else:
                println("the real thing")
        Result.Err(_) -> println("no such path")

    return Result.Ok(0)

read_dir

List the entries of a directory.

fn read_dir(string path) -> Result@(string[], FileError)

Parameters: - path - Directory path to list

Returns: - Result.Ok(string[]) - The entry names. . and .. are not included. - Result.Err(FileError) - Failed (not found, permission denied, not a directory)

Example:

use <io/files>

fn main() i32:
    match read_dir("/tmp"):
        Result.Ok(entries) ->
            foreach(name in entries.iter()):
                println(name)
        Result.Err(_) -> println("Cannot list /tmp")

    return Result.Ok(0)

Notes: - The result holds entry NAMES, not paths. Join with the directory yourself. - The order is the order the OS returns; it is unspecified. Do not depend on it. - Every kind of entry is listed: files, directories, symlinks.

mkdir

Create a new directory with specified permissions.

fn mkdir(string path, i32 mode) -> Result@(i32, FileError)

Parameters: - path - Directory path to create - mode - Permissions in octal format (e.g., 0o755, 0o700)

Returns: - Result.Ok(0) - Directory created successfully - Result.Err() - Failed (already exists, permission denied, parent doesn't exist)

Example:

use <io/files>

fn main() i32:
    match mkdir("/tmp/mydir", 0o755):
        Result.Ok(_) ->
            println("Directory created")
        Result.Err(_) ->
            println("Failed to create directory")

    return Result.Ok(0)

Common permissions: - 0o755 - Owner: rwx, Group: r-x, Others: r-x - 0o700 - Owner: rwx, Group: ---, Others: --- - 0o775 - Owner: rwx, Group: rwx, Others: r-x

Note: Does not create parent directories. All parents must already exist.

rmdir

Remove an empty directory.

fn rmdir(string path) -> Result@(i32, FileError)

Parameters: - path - Directory path to remove

Returns: - Result.Ok(0) - Directory removed successfully - Result.Err() - Failed (doesn't exist, not empty, permission denied)

Example:

use <io/files>

fn main() i32:
    match rmdir("/tmp/mydir"):
        Result.Ok(_) ->
            println("Directory removed")
        Result.Err(_) ->
            println("Failed to remove directory")

    return Result.Ok(0)

Important: Directory must be empty. Use remove() to delete files inside first.

copy

Copy a file's contents to a new location.

fn copy(string src, string dst) -> Result@(i32, FileError)

Parameters: - src - Source file path - dst - Destination file path

Returns: - Result.Ok(0) - File copied successfully - Result.Err() - Failed (source doesn't exist, permission denied, I/O error)

Example:

use <io/files>
use <io/fs>

fn main() i32:
    match copy("/tmp/source.txt", "/tmp/backup.txt"):
        Result.Ok(_) ->
            println("File copied")
        Result.Err(_) ->
            println("Failed to copy file")

    return Result.Ok(0)

Note: Overwrites destination if it exists. Uses efficient 4KB buffering internally.

File Utility Pattern Matching Example

use <io/files>

fn backup_and_cleanup(string path) ~:
    let string backup = "{path}.bak"

    # path is used twice below; clone it for the first call so it stays
    # usable for the second (a by-value string argument moves)
    match copy(path.clone(), backup):
        Result.Ok(_) ->
            println("Backup created")
        Result.Err(_) ->
            return Result.Err(StdError.Error)

    match remove(path):
        Result.Ok(_) ->
            println("Original removed")
        Result.Err(_) ->
            println("Cleanup failed")
            return Result.Err(StdError.Error)

    return Result.Ok(~)

fn main() i32:
    match backup_and_cleanup("/tmp/data.txt"):
        Result.Ok(_) ->
            println("Operation complete")
        Result.Err(_) ->
            println("Operation failed")

    return Result.Ok(0)

The descriptor layer

Underneath the file handle sits a thin layer over the raw descriptor. It is the same shape <net/socket> gives <net/tcp> — the primitives a handle type is written on top of — and it comes in two halves.

Positional: fd_pread and fd_pwrite take the offset as an argument and never move the descriptor's own file position, which is what makes them safe to share.

Sequential: fd_read, fd_write, fd_write_str, fd_readln and fd_seek move it, which is what makes them the ones a handle is written on.

fd_open, fd_dup, fd_close and fd_isatty belong to neither half.

fd_pread(i32 fd, i64 offset, i32 max) -> Result@(u8[], FileError)

fd_pwrite(i32 fd, i64 offset, u8[] data) -> Result@(i32, FileError)

Read or write at an offset without moving the descriptor's file position. That is what makes them the answer for concurrent reads of one file: the offset is an argument, so nothing is shared and nothing can race. Every language that supports concurrent file I/O converged on this primitive — C and POSIX have pread(2) and pwrite(2), Go has File.ReadAt, Rust has FileExt::read_at, Java takes a position argument on FileChannel.read — and none of them needed a new kind of type.

fd_pread answers what ARRIVED, which may be fewer bytes than asked for and is empty at end of file. fd_pwrite answers the count it took; looping until the whole buffer is gone is the caller's job.

fd_open(string path, i32 intent, i32 mode) -> Result@(i32, FileError)

intent says what the caller WANTS, not what the platform calls it:

intent means
0 read only
1 write: create, truncate
2 append: create, append
3 read and write: create, keep

The O_* flag values differ between macOS and Linux, and Sushi has no conditional compilation, so a number spelled in portable source would be wrong on one of them. The intent crosses the boundary and the platform layer maps it. An unrecognised number opens read-only, which is the safe reading of a value this function does not know.

mode is the permission bits a newly created file gets, as an integer — 420 is 0644.

fd_read(i32 fd, i32 max) -> Result@(u8[], FileError)

ONE read(2) from the descriptor's current position, which it advances. The answer carries what ARRIVED and may be shorter than asked for; an empty array is end of file and not an error, so a caller loops until the answer is empty.

fd_write(i32 fd, u8[] data) -> Result@(i32, FileError)

fd_write_str(i32 fd, string s) -> Result@(i32, FileError)

Write every byte, looping past a short write, and answer the count. fd_write_str takes the string's own bytes with no to_bytes() copy in front of them — a fat pointer already carries a pointer and a length, which is what write(2) wants.

Unlike sock_send, these do not hand a partial write back to the caller. A socket's partial write is information — how much the peer's window took — and a file's is not.

fd_readln(i32 fd) -> Result@(Maybe@(string), FileError)

One line, the newline stripped, in a Maybe. A blank line is Maybe.Some("") and the end of the file is Maybe.None, so the two are never the same answer. It used to answer an empty string for both, which truncated a file at its first blank line and made a failed read look like a clean end.

It has two paths, and which one runs depends on whether the descriptor can seek:

descriptor how it reads why
a file, or anything seekable pread a chunk, then one lseek past the newline fast: 0.60s for 200 000 lines, against 0.47s for the buffered fgets it replaces
a pipe, a socket, a terminal — including stdin one byte at a time correct: a pipe cannot give back an over-read, so a chunked read would swallow bytes the next reader owns

The byte-at-a-time path costs 4.49s over the same 200 000 lines, nearly all of it in the kernel. That is the price of not losing data, and it is only paid where it must be. For bulk line reading over any descriptor, the buffered reader is the general answer.

fd_seek(i32 fd, i64 offset, i32 whence) -> Result@(i64, FileError)

Move the descriptor's file position, and answer the NEW position. whence is an intent like fd_open's: 0 from the start, 1 from the current position, 2 from the end.

There is no fd_tell, because it would say nothing new: the current position is fd_seek(fd, 0, 1).

fd_isatty(i32 fd) -> bool

Whether the descriptor is a terminal. A bare bool, not a Result: a descriptor that is not a terminal and a descriptor that is not open both answer false, so there is no failure a caller could act on.

fd_dup(i32 fd) -> Result@(i32, FileError)

A second descriptor over the same open file description. The offset is shared, so this is for the shared-listener pattern and not for concurrent reads of one file — fd_pread and fd_pwrite are that. File.share() is written on it.

fd_close(i32 fd) -> Result@(i32, FileError)

Close one descriptor.

Common Patterns

Reading entire file

use <io/files>
use <io/fs>

fn read_file(string path) string | IoError:
    let File f = open(path, FileMode.Read())??
    return Result.Ok(f.read_all()??)

fn main() i32:
    match read_file("data.txt"):
        Result.Ok(content) -> println(content)
        Result.Err(_) -> println("could not read it")

    return Result.Ok(0)

Writing entire file

use <io/files>
use <io/fs>

fn write_file(string path, string data) ~ | IoError:
    let File f = open(path, FileMode.Write())??
    f.write(data)??
    return Result.Ok(~)

fn main() i32:
    match write_file("output.txt", "Mostly Harmless"):
        Result.Ok(_) -> println("File written")
        Result.Err(_) -> println("could not write it")

    return Result.Ok(0)

Processing CSV file

use <io/files>
use <io/fs>
use <collections/strings>

fn show_csv(string path) ~ | IoError:
    let File f = open(path, FileMode.Read())??
    let string content = f.read_all()??
    let string[] lines = content.split("\n")

    foreach(line in lines.iter()):
        if (not line.is_empty()):
            let string[] fields = line.split(",")

            foreach(field in fields.iter()):
                print("{field}\t")

            println("")

    return Result.Ok(~)

fn main() i32:
    match show_csv("data.csv"):
        Result.Ok(_) -> println("read")
        Result.Err(_) -> println("failed")

    return Result.Ok(0)

Creating log file

use <io/files>
use <io/fs>

fn log_message(string message) ~ | IoError:
    let File f = open("app.log", FileMode.Append())??
    f.writeln(message)??
    return Result.Ok(~)

fn run() ~ | IoError:
    log_message("Application started")??
    log_message("Processing data")??
    log_message("Application finished")??
    return Result.Ok(~)

fn main() i32:
    match run():
        Result.Ok(_) -> println("logged")
        Result.Err(_) -> println("could not log")

    return Result.Ok(0)

Checking file existence

exists() answers a bare bool and opens nothing, so it costs one stat and cannot leave a descriptor behind. Note that an answer is only ever a statement about the past: between the check and the open, another process can create or remove the path, so a program that must not race should open the file and read the error instead.

use <io/files>

fn main() i32:
    if (exists("config.txt")):
        println("Config file found")
    else:
        println("Config file not found")

    return Result.Ok(0)

Platform Behavior

Path Separators

  • Unix/Linux/macOS: Forward slash /
  • Recommended: Use forward slashes for cross-platform compatibility
use <io/fs>

fn read_input() string | IoError:
    # Unix-style paths work on all platforms
    let File f = open("data/input.txt", FileMode.Read())??
    return Result.Ok(f.read_all()??)

fn main() i32:
    match read_input():
        Result.Ok(text) -> println(text)
        Result.Err(_) -> println("no input")

    return Result.Ok(0)

Line Endings

Different platforms use different line endings: - Unix/Linux/macOS: \n (LF) - Windows: \r\n (CRLF)

Sushi uses \n internally. When reading files, line endings are preserved.

File Permissions

File permissions are platform-specific: - Unix/Linux/macOS: Standard POSIX permissions (user/group/other) - Windows: ACLs

PermissionDenied error occurs when the process lacks required permissions.

Performance Considerations

Buffering

A File does not buffer. Every read and every write on a handle is one system call, so a loop of small reads pays for one call each. That is the cost BufReader and BufWriter exist to remove: they read a window and hand out of it, so a line-at-a-time loop costs one call per window instead of one per line.

use <io/fs>
use <io/buf>

fn show(string path) ~ | IoError:
    let File f = open(path, FileMode.Read())??
    let BufReader@(File) r = BufReader.new(nom f, 8192)??

    foreach(line?? in r.lines()):
        println(line)

    return Result.Ok(~)

fn main() i32:
    match show("large.txt"):
        Result.Ok(_) -> println("read")
        Result.Err(_) -> println("failed")

    return Result.Ok(0)

Memory Usage

  • read_all() loads the whole file into memory, however large it is
  • readln() holds one line at a time, at the cost of one system call per line
  • BufReader.read_line(), and foreach over r.lines(), hold one line at a time and pay one system call per WINDOW

Choose by file size: read_all() for a configuration file, the buffered loop for anything a user might grow without asking.

Security Considerations

Path Traversal

Always validate file paths from user input:

use <io/files>
use <io/fs>
use <collections/strings>

extend string is_safe_path() bool:
    # Reject paths with ..
    if (self.contains("..")):
        return false

    # Reject absolute paths if needed
    if (self.starts_with("/")):
        return false

    return true

fn read_checked(string path) string | IoError:
    if (not path.is_safe_path()):
        return Result.Err(IoError.InvalidInput)

    let File f = open(path, FileMode.Read())??
    return Result.Ok(f.read_all()??)

fn main() i32:
    match read_checked("data.txt"):
        Result.Ok(content) -> println(content)
        Result.Err(IoError.InvalidInput) -> println("Invalid path")
        Result.Err(_) -> println("could not read it")

    return Result.Ok(0)

File Permissions

Be cautious with write operations:

use <io/files>
use <io/fs>

fn overwrite() ~ | IoError:
    # FileMode.Write() TRUNCATES the existing file.
    let File f = open("important.txt", FileMode.Write())??
    f.write("New content")??
    return Result.Ok(~)

fn main() i32:
    match overwrite():
        Result.Ok(_) -> println("written")
        Result.Err(_) -> println("Failed to open file")

    return Result.Ok(0)

Use FileMode.Append() to preserve existing content.

See Also