Chapter 10 — Files and input/output

This chapter covers how a program talks to the outside world: the terminal, files, directories, environment variables and command-line arguments. All of it can fail, so every function here returns a Result, and a program needs permission before it can touch files or the environment at all.

Permissions#

A Coil program cannot read files, write files, use the network, read environment variables or start processes unless you allow it when you run it. The compiler works out what the program can reach and stops with a clear message when a flag is missing:

Error: [E0414] `io::open` requires `--allow-read`: reached from `main` → `io::file::read_text` → `io::file::read_bytes`
    Help: pass `--allow-read` (or `-A` for everything)

The flags are --allow-read, --allow-write, --allow-net, --allow-env, --allow-exec and --allow-exit; -A allows them all. In a spool project, list them in coil.toml under [permissions]. Printing and reading standard input need no permission. The details are on the permissions page.

The examples in this chapter need -A (or the specific flags):

coil --root ~/.coil/stdlib/src -A example.hy

The terminal#

io::sync has println, print (no newline) and eprintln (to standard error, for messages that are not part of the output). read_line reads one line from a stream, without the line ending, and returns None at the end of input:

use io::{stdin};
use io::sync::{println, read_line};
use string::{format};

fn main() {
    let input = stdin();
    let count = 0;
    while let Some(line) = read_line(input)? {
        count++;
        println(format("%i: %s", count, line))?;
    }
    println(format("%i lines", count))?;
}

Run it with input piped in, printf 'a\nb\n' | coil … number.hy:

1: a
2: b
2 lines

Reading and writing files#

For whole files, io::file has one-call functions:

use io::sync::{println};
use io::file::{read_text, write_text, append_text};
use text::{lines};
use string::{format};

fn main() {
    write_text("todo.txt", "buy milk\n")?;
    append_text("todo.txt", "call Ada\n")?;

    let body = read_text("todo.txt")?;
    let todo = lines(body) ?? Vec::new();
    println(format("%i things to do, first: %s", todo.len(), todo[0]))?;
}

Output:

2 things to do, first: buy milk

write_text replaces the file, append_text adds to its end, and both create it when it does not exist. read_bytes and write_bytes do the same with raw bytes (Vec<byte>).

When a file cannot be opened, the Result holds an IoError such as NotFound or PermissionDenied, which you can match on:

use io::sync::{println};
use io::file::{read_text};

fn main() {
    match read_text("settings.conf") {
        Ok(text) => {
            println(text)?;
        },
        Err(IoError::NotFound) => {
            println("no settings file, using defaults")?;
        },
        Err(e) => {
            println("cannot read settings: " + show(e))?;
        },
    }
}

Output:

no settings file, using defaults

Streams#

For large files, or when you want to read and write piece by piece, open a Stream with io::open and a mode ("r", "w", "a", …). io::sync has helpers that wait until the work is done:

use io::{open, close};
use io::sync::{println, write_all, read_to_end};
use string::{to_bytes, from_bytes};

fn main() {
    let out = open("log.txt", "w")?;
    defer use (out) {
        let _ = close(out);
    }
    write_all(out, to_bytes("started\n"))?;
    write_all(out, to_bytes("finished\n"))?;
}

defer makes sure the file is closed however the function returns. Reading works the same way with read_to_end, read_exact or read_line.

Directories#

io::fs works with paths: exists, is_dir, list_dir, create_dir_all, rename, copy, remove_file, remove_dir_all and more:

use io::sync::{println};
use io::fs::{create_dir_all, list_dir, remove_dir_all};
use io::file::{write_text};

fn main() {
    create_dir_all("reports/2026")?;
    write_text("reports/2026/jan.txt", "…")?;
    write_text("reports/2026/feb.txt", "…")?;
    for name in list_dir("reports/2026")? {
        println(name)?;
    }
    remove_dir_all("reports")?;
}

Output:

feb.txt
jan.txt

The stdlib’s Path class wraps a path string with methods such as join, dirname, basename and extension.

Arguments and environment#

env gives you the command-line arguments, environment variables, the working directory and the exit status:

use io::sync::{println, eprintln};
use env::{args, var, exit};

fn main() {
    let argv = args() ?? Vec::new();
    if argv.len() < 2 {
        eprintln("usage: greet NAME")?;
        exit(2);
    }
    let greeting = var("GREETING") ?? "Hello";
    println(greeting + ", " + argv[1])?;
}

args() starts with the program’s own name. Pass arguments to a spool project with spool run -- Ada, or build an executable with coil package and run that. exit needs --allow-exit and var needs --allow-env.

Networking#

TCP and UDP sockets are in io::net. A server usually handles many connections at once, which is what tasks are for: chapter 13 builds a small concurrent server. For HTTP, TLS and other protocols, see the packages.

Exercises#

  1. Write a program that counts the lines, words and bytes of a file, like wc.
  2. Read numbers from standard input, one per line, and print their sum. Skip lines that are not numbers (conv::parse_int) and report how many were skipped on standard error.
  3. Copy every .txt file in a directory into a backup/ subdirectory.

Next: Chapter 11 — Testing.

↑ ↓ navigate ↵ open