module io

module lib/io.mu

import "io"

io is the file and descriptor API.

Everything here is a COMPLETE operation: io.write writes all of its data or returns an error, io.read_all reads to end of file, io.readln returns one whole line. io.read is the exception and says so — it is one read(2), and a short result is normal.

io also owns the platform knowledge underneath those operations: which open syscall this kernel wants, what its O_* flags are numbered, and how its directory records are laid out. The layer below that is the syscall builtin itself; a program that wants one raw unlooped write calls syscall("write", fd, text, len(text)) rather than reaching for a module.

Imports

Values

stderr#

stderr

stderr is descriptor 2, the process's standard error.

Source lib/io.mu:26
stderr := 2

stdin#

stdin

stdin is descriptor 0, the process's standard input.

Source lib/io.mu:20
stdin := 0

stdout#

stdout

stdout is descriptor 1, the process's standard output.

Source lib/io.mu:23
stdout := 1

Functions

append_file#

fn append_file(path, data)

append_file appends data to path, creating it if it does not exist.

Source lib/io.mu:232
fn append_file(path, data) {
    return _write_whole_file("append_file", path, data, "a")
}

close#

fn close(fd)

close closes a file descriptor.

Source lib/io.mu:61
fn close(fd) {
    if !typing.is_int(fd) {
        return error("io.close expects integer fd, got " + type(fd))
    }

    result := syscall("close", fd)

    if is_error(result) {
        return result
    }

    // Drop any readln carry-over for this descriptor. The kernel recycles fd
    // numbers, so a buffer that outlived its close would hand the next file
    // opened onto the same number another file's leftover bytes.
    del(_readln_bufs, str(fd))
    return nil
}

exists#

fn exists(path)

exists reports whether path names anything at all (file, directory, or otherwise). It follows symlinks, so a dangling link answers false.

Source lib/io.mu:404
fn exists(path) {
    return !is_error(stat(path))
}

is_dir#

fn is_dir(path)

is_dir reports whether path names a directory. A path that does not exist answers false rather than an error, so it can gate a mkdir directly.

Source lib/io.mu:411
fn is_dir(path) {
    info := stat(path)

    if is_error(info) {
        return false
    }

    return info["is_dir"]
}

list_dir#

fn list_dir(path)

list_dir returns the entry names in a directory, excluding "." and "..".

This is the one platform-specific thing io does, because there is no portable half to lift out: linux and darwin disagree on both the syscall and the layout of the records it returns.

Source lib/io.mu:493
fn list_dir(path) {
    if !typing.is_str(path) {
        return error("io.list_dir expects string path, got " + type(path))
    }

    flags := _open_flags("d")

    if is_error(flags) {
        return flags
    }

    // O_DIRECTORY fails on a non-directory, so this open is also the
    // is-it-a-directory check.
    fd := _open_fd(path, flags)

    if is_error(fd) {
        return _path_error("list_dir", path, fd)
    }

    if fd < 0 {
        return error("io.list_dir: cannot open " + path)
    }

    p := platform()
    names := nil

    if p == "darwin/amd64" || p == "darwin/arm64" {
        names = _read_dents_darwin(fd)
    } else {
        if p == "linux/amd64" || p == "linux/arm64" || p == "linux/riscv64" {
            names = _read_dents_linux(fd)
        } else {
            names = error("io.list_dir: unsupported platform " + p)
        }
    }

    close(fd)
    return names
}

mkdir#

fn mkdir(path)

mkdir creates a directory with mode 0755. The parent must already exist; creating a path that already exists is an error, as it is everywhere else.

Source lib/io.mu:424
fn mkdir(path) {
    if !typing.is_str(path) {
        return error("io.mkdir expects string path, got " + type(path))
    }

    result := syscall("mkdirat", 0 - 100, path + chr(0), 493)

    if is_error(result) {
        return _path_error("mkdir", path, result)
    }

    return nil
}

open#

fn open(path, mode)

open opens a file path in mode "r" (read), "w" (truncate) or "a" (append), and returns a file descriptor.

Source lib/io.mu:31
fn open(path, mode) {
    if !typing.is_str(path) {
        return error("io.open expects string path, got " + type(path))
    }

    if !typing.is_str(mode) {
        return error("io.open expects string mode, got " + type(mode))
    }

    if mode != "r" && mode != "w" && mode != "a" {
        return error("io.open expects mode \"r\", \"w\" or \"a\", got \"" + mode + "\"")
    }

    flags := _open_flags(mode)

    if is_error(flags) {
        return flags
    }

    fd := _open_fd(path, flags)

    if is_error(fd) {
        return _path_error("open", path, fd)
    }

    return fd
}

read#

fn read(fd, size)

read issues ONE read(2) of up to size bytes from fd and returns them as a string. A short read is normal and does not mean end of file; only "" does. Use read_all to read a descriptor to exhaustion.

Source lib/io.mu:83
fn read(fd, size) {
    if !typing.is_int(fd) {
        return error("io.read expects integer fd, got " + type(fd))
    }

    if !typing.is_int(size) {
        return error("io.read expects integer size, got " + type(size))
    }

    if size < 0 {
        return error("io.read expects non-negative size, got " + str(size))
    }

    return _read_chunk(fd, size)
}

read_all#

fn read_all(fd)

read_all reads fd to end of file and returns everything it produced.

Source lib/io.mu:101
fn read_all(fd) {
    if !typing.is_int(fd) {
        return error("io.read_all expects integer fd, got " + type(fd))
    }

    parts := []

    while true {
        chunk := _read_chunk(fd, 65536)

        if is_error(chunk) {
            return chunk
        }

        if len(chunk) == 0 {
            return strings.join(parts, "")
        }

        parts = append(parts, chunk)
    }

    return strings.join(parts, "")
}

read_all_each#

fn read_all_each(fds)

read_all_each reads every fd in fds to end of file AT THE SAME TIME and returns one result per fd, in order (each a string or an error value).

Each read runs in its own task, and the scheduler parks a task whose fd is quiet, so one blocked pipe cannot stall the others. process.run needs exactly this: a child that fills one pipe buffer blocks in write(2) until the parent drains it, so reading a child's stdout and stderr one after the other can deadlock against a child interleaving both. The helper lives here rather than in process because that module's own wait() shadows the task builtin of the same name.

Source lib/io.mu:136
fn read_all_each(fds) {
    tasks := []
    i := 0

    while i < len(fds) {
        tasks = append(tasks, task(read_all, fds[i]))
        i = i + 1
    }

    out := []
    i = 0

    while i < len(tasks) {
        out = append(out, wait(tasks[i]))
        i = i + 1
    }

    return out
}

read_file#

fn read_file(path)

read_file opens path, reads the whole file, closes it, and returns its text.

Source lib/io.mu:158
fn read_file(path) {
    if !typing.is_str(path) {
        return error("io.read_file expects string path, got " + type(path))
    }

    fd := open(path, "r")

    if is_error(fd) {
        return fd
    }

    data := read_all(fd)

    if is_error(data) {
        close(fd)
        return _path_error("read", path, data)
    }

    closed := close(fd)

    if is_error(closed) {
        return _path_error("close", path, closed)
    }

    return data
}

readln#

fn readln()

readln reads a single line from stdin, without its line ending. It answers nil when there is nothing left to read.

Source lib/io.mu:270
fn readln() {
    return readln_from(stdin)
}

readln_from#

fn readln_from(fd)

readln_from reads a single line from fd, without its line ending.

It reads one chunk at a time so an interactive terminal is not held waiting for end of file, and keeps whatever followed the line ending for the next call. A final line with no trailing newline is still returned; nil means there is nothing left.

Source lib/io.mu:285
fn readln_from(fd) {
    if !typing.is_int(fd) {
        return error("io.readln_from expects integer fd, got " + type(fd))
    }

    key := str(fd)
    buffer := _readln_bufs[key]

    if buffer == nil {
        buffer = ""
    }

    while true {
        ending := strings.find_newline(buffer)

        if ending[0] >= 0 {
            idx := ending[0]
            skip := ending[1]
            line := strings.substring(buffer, 0, idx)
            _readln_bufs[key] = strings.substring(buffer, idx + skip, len(buffer))
            return line
        }

        chunk := _read_chunk(fd, 1024)

        if is_error(chunk) {
            return chunk
        }

        if len(chunk) == 0 {
            _readln_bufs[key] = ""

            if len(buffer) > 0 {
                return buffer
            }

            return nil
        }

        buffer = buffer + chunk
    }
}

remove#

fn remove(path)

remove deletes a file (not a directory). unlinkat on linux, because the asm-generic ABI (arm64, riscv64) has no unlink(2); unlink on darwin.

Source lib/io.mu:466
fn remove(path) {
    if !typing.is_str(path) {
        return error("io.remove expects string path, got " + type(path))
    }

    platform_key := platform()
    result := nil

    if platform_key == "darwin/amd64" || platform_key == "darwin/arm64" {
        result = syscall("unlink", path + chr(0))
    } else {
        result = syscall("unlinkat", 0 - 100, path + chr(0), 0)
    }

    if is_error(result) {
        return _path_error("remove", path, result)
    }

    return nil
}

rename#

fn rename(old, new)

rename atomically renames (moves) old to new within a filesystem, replacing new if it exists. renameat2 on linux -- the only rename the riscv64 kernel has -- and renameat on darwin, which has no renameat2.

Source lib/io.mu:442
fn rename(old, new) {
    if !typing.is_str(old) || !typing.is_str(new) {
        return error("io.rename expects string paths, got " + type(old) + " and " + type(new))
    }

    platform_key := platform()
    result := nil

    if platform_key == "darwin/amd64" || platform_key == "darwin/arm64" {
        result = syscall("renameat", 0 - 100, old + chr(0), 0 - 100, new + chr(0))
    } else {
        result = syscall("renameat2", 0 - 100, old + chr(0), 0 - 100, new + chr(0), 0)
    }

    if is_error(result) {
        return _path_error("rename", old, result)
    }

    return nil
}

stat#

fn stat(path)

stat returns a file's metadata: a map with "size" (bytes), "mode" (the raw st_mode), "is_dir"/"is_file", "mtime" (Unix seconds) and "mtime_ns" (the same instant in nanoseconds). Symlinks are followed. An error value answers a path that does not exist -- use exists() for the boolean question.

One syscall serves every platform -- fstatat(AT_FDCWD, path, buf, 0) -- but the struct it fills is laid out three ways, and this module owns that knowledge the same way it owns dirent layouts: mode is a 32-bit word at 24 on linux/amd64 and at 16 on asm-generic linux (arm64, riscv64), and a 16-bit word at 4 on darwin; size sits at 48 on linux and 96 on darwin; the modification timespec sits at 88/96 on linux and 48/56 on darwin.

Source lib/io.mu:340
fn stat(path) {
    if !typing.is_str(path) {
        return error("io.stat expects string path, got " + type(path))
    }

    b := buf(144)
    result := syscall("fstatat", 0 - 100, path + chr(0), b, 0)

    if is_error(result) {
        return _path_error("stat", path, result)
    }

    platform_key := platform()
    mode := 0
    size := 0
    sec := 0
    nsec := 0

    if platform_key == "darwin/amd64" || platform_key == "darwin/arm64" {
        mode = _stat_word(b, 4, 2)
        size = _stat_word(b, 96, 8)
        sec = _stat_word(b, 48, 8)
        nsec = _stat_word(b, 56, 8)
    } else {
        if platform_key == "linux/amd64" {
            mode = _stat_word(b, 24, 4)
            size = _stat_word(b, 48, 8)
            sec = _stat_word(b, 88, 8)
            nsec = _stat_word(b, 96, 8)
        } else {
            if platform_key == "linux/arm64" || platform_key == "linux/riscv64" {
                mode = _stat_word(b, 16, 4)
                size = _stat_word(b, 48, 8)
                sec = _stat_word(b, 88, 8)
                nsec = _stat_word(b, 96, 8)
            } else {
                return error("io.stat: unsupported platform " + platform_key)
            }
        }
    }

    kind := mode & 61440
    return {"size": size, "mode": mode, "is_dir": kind == 16384, "is_file": kind == 32768, "mtime": sec, "mtime_ns": sec * 1000000000 + nsec}
}

write#

fn write(fd, data)

write writes ALL of data to fd and returns the byte count. A single write(2) may accept less than it was offered — it returns how much it took — so this loops until nothing is left.

Source lib/io.mu:189
fn write(fd, data) {
    if !typing.is_int(fd) {
        return error("io.write expects integer fd, got " + type(fd))
    }

    if !typing.is_str(data) {
        return error("io.write expects string data, got " + type(data))
    }

    total := 0
    length := len(data)

    while total < length {
        chunk := data

        if total > 0 {
            chunk = strings.substring(data, total, length)
        }

        wrote := syscall("write", fd, chunk, len(chunk))

        if is_error(wrote) {
            return wrote
        }

        if wrote <= 0 {
            return error("io.write made no progress with " + str(length - total) + " bytes remaining")
        }

        total = total + wrote
    }

    return total
}

write_file#

fn write_file(path, data)

write_file writes data to path, replacing any existing file.

Source lib/io.mu:226
fn write_file(path, data) {
    return _write_whole_file("write_file", path, data, "w")
}

Internal helpers

Underscore-prefixed names are implementation detail. They are listed so the module's source reads without surprises, not as API — they may change at any time.

_open_fd(path, flags)
_open_flags(mode)
_path_error(action, path, err)
_read_chunk(fd, size)
_read_dents_darwin(fd)
_read_dents_linux(fd)
_readln_bufsPer-fd carry-over buffers: bytes read past a line's ending are kept here for the next readln instead of being discarded.
_stat_word(b, off, width)
_write_whole_file(caller, path, data, mode)