Axle v0.14.1

Files and I/O

std/io ships the file-and-stream API. The pieces that cover most needs:

  • Path (std/io/path) — whole-file convenience: readText / writeText / appendText, plus lexical path work.
  • File — a path wrapper for exists / length / delete and for opening the byte streams below (openRead / openWrite / openAppend).
  • FileInputStream / FileOutputStream — byte-oriented streams.
  • BufferedReader / BufferedWriter — line-by-line and chunked I/O, wrap a stream.
  • Scanner — typed line reads from standard input (nextI32, nextF64, …).

Every stream class satisfies Closeable — pair every open with a defer close(). There is no whole-file File.readAll / writeAll; reach for Path::readText / writeText (small files) or stream the bytes (large files).

Read a whole file

use std::io::path::Path;
use std::lang::IOException;

fn loadText(path : string) : string ! IOException {
    return Path::from(path).readText();         // slurps the whole file
}

fn main() : i32 {
    try {
        let s : string = loadText("/etc/hostname");
        // … use s …
        return 0;
    } catch e : IOException {
        return 1;
    }
}

Path::readText() slurps the entire file into a string. Only appropriate for files that fit in memory.

Write a whole file

use std::io::path::Path;

fn saveText(path : string, content : string) : void ! IOException {
    Path::from(path).writeText(content);        // truncates and writes
}

writeText overwrites any existing content; appendText adds to the end. For streaming append, see FileOpenMode::APPEND below.

Existence and metadata

use std::io::File;

fn dump(path : string) : void ! IOException {
    let f : File = new File(path);
    defer f.close();
    if (!f.exists()) {
        // An absent file reads as zero bytes.
        return;
    }
    let bytes : i64 = f.length();
    // … print bytes …
}

exists() and length() don’t throw — they short-circuit on errors. A File satisfies Closeable, so the compiler still wants a visible close() even though a File holds no open handle and its close() is a no-op — pair it with defer like any other resource.

Line-by-line read

use std::io::BufferedReader;
use std::io::FileInputStream;

fn countLines(path : string) : i32 ! IOException, FileNotFoundException {
    let reader : BufferedReader = new BufferedReader(new FileInputStream(path));
    defer reader.close();

    let count : i32 = 0;
    // readLine() returns "" at end of stream.
    let line : string = reader.readLine();
    while (!line.isEmpty()) {
        count = count + 1;
        line = reader.readLine();
    }
    return count;
}

BufferedReader takes ownership of the FileInputStream handle — closing the reader closes the file. Construct the stream inline in the wrapper’s constructor (as above) rather than binding it to its own local: a named stream is tracked as a second resource that would demand its own close(), whereas the inline stream is owned wholly by the reader, so one defer reader.close() covers both. readLine() strips the trailing newline and returns "" at end of stream ; a blank line in the middle of a file also reads as "", so count files containing blank lines another way (e.g. Path::readText plus a newline scan).

Line-by-line write

use std::io::BufferedWriter;
use std::io::FileOutputStream;
use std::io::FileOpenMode;
use std::collections::ArrayList;

fn writeLines(
  path : string, lines : ArrayList<string>
) : void ! IOException {
    let writer : BufferedWriter = new BufferedWriter(new FileOutputStream(path, FileOpenMode::WRITE));
    defer writer.close();                       // close flushes too — and closes the stolen stream

    let i : i32 = 0;
    while (i < lines.size()) {
        writer.writeLine(lines.get(i) ?? "");   // in range — never null
        i = i + 1;
    }
}

The mode : FileOpenMode argument on FileOutputStream:

  • FileOpenMode::WRITE truncates the file before writing.
  • FileOpenMode::APPEND opens for append (writes go after existing content).

writer.close() flushes the buffer ; an explicit flush() is only needed if you want to commit bytes before close (e.g. before a long pause).

Typed reads from stdin — Scanner

Scanner reads standard input line by line and parses each line as a typed value:

use std::io::Scanner;

fn sumIntegers() : i64 ! IOException, ParseException {
    let scanner : Scanner = new Scanner();
    defer scanner.close();

    let sum : i64 = 0;
    while (scanner.hasNextLine()) {
        sum = sum + scanner.nextI64();
    }
    return sum;
}

Scanner exposes nextLine / nextI32 / nextI64 / nextF64 — each reads one line and throws ParseException when the line doesn’t represent the requested type. For typed reads from a file, read lines with BufferedReader and parse them with std::text (text::parseI32, …).

Append-only logging

use std::io::FileOutputStream;
use std::io::FileOpenMode;
use std::io::PrintWriter;

fn appendLog(path : string, message : string) : void ! IOException {
    let writer : PrintWriter = new PrintWriter(new FileOutputStream(path, FileOpenMode::APPEND));
    defer writer.close();
    writer.println(message);                 // appends message + a newline
}

FileOpenMode::APPEND opens at end-of-file ; concurrent writers from separate processes will serialise on most OSes (POSIX guarantees atomic append up to PIPE_BUF bytes).

Chunked binary read

use std::io::FileInputStream;
use std::io::FileOutputStream;
use std::io::FileOpenMode;

fn copyStream(src : string, dst : string) : void ! IOException, FileNotFoundException {
    let input : FileInputStream = new FileInputStream(src);
    defer input.close();

    let output : FileOutputStream = new FileOutputStream(dst, FileOpenMode::WRITE);
    defer output.close();

    let b : i32 = input.read();          // next byte 0..255, or -1 at end
    while (b >= 0) {
        output.write(b);                 // writes the low 8 bits of b
        b = input.read();
    }
}

The byte streams are unbuffered and byte-oriented: read() returns the next byte (0..255) or -1 at end of stream, and write(b) emits the low 8 bits of b. available() reports the bytes still remaining to EOF. For bulk text, wrap the stream in a BufferedReader, or reach for Path::readText / writeText.

In-memory streams

For tests or buffer manipulation without touching disk:

use std::io::ByteArrayInputStream;
use std::io::ByteArrayOutputStream;

fn copyThroughMemory(payload : string) : string ! IOException {
    let input : ByteArrayInputStream = ByteArrayInputStream::fromString(payload);
    defer input.close();

    let output : ByteArrayOutputStream = new ByteArrayOutputStream();
    defer output.close();

    let b : i32 = input.read();              // next byte, or -1 at end
    while (b >= 0) {
        output.write(b);
        b = input.read();
    }
    return output.toString();
}

ByteArrayInputStream::fromString(s) copies a string’s bytes into an in-memory read cursor: read() returns the next byte (-1 at end), alongside available() and close(). The plain new ByteArrayInputStream(data, len) constructor wraps a raw i8[] without copying.

Console output — PrintWriter

PrintWriter wraps a FileOutputStream (e.g. stdout would be a fixed path on POSIX /dev/stdout) and adds print / println / printf:

use std::io::PrintWriter;
use std::io::FileOutputStream;

fn report(out : PrintWriter) : void ! IOException {
    out.println("== summary ==");
    out.print("count = ");
    out.println((42).toString());
}

For plain stdout / stderr, print / println (auto-imported from the runtime, output-only) are the simpler choice.

Whole-file copy

use std::io::path::Path;

fn copy(src : string, dst : string) : void ! IOException {
    Path::from(dst).writeText(Path::from(src).readText());
}

Path also has a direct copy(to) method. Either form is fine for small files; large files should use the chunked copyStream variant above to avoid pulling the whole content into RAM.

Paths — Path

std::io::path (alias std::path) ships a Path class for lexical path manipulation and direct file operations:

use std::io::path::Path;

fn main() : i32 ! IOException {
    let p : Path = Path::from("/tmp/notes/todo.txt");

    let dir : Path = p.parent();              // /tmp/notes
    dir.mkdirs();                             // create, parents included

    p.writeText("first line\n");
    p.appendText("second line\n");
    let text : string = p.readText();

    if (!p.exists() || !p.isFile())        { return 1; }
    if (!p.extension().equals(".txt"))     { return 2; }
    if (!p.fileName().equals("todo.txt"))  { return 3; }

    p.delete();
    return 0;
}
GroupMethods
BuildPath::from(s), Path::cwd(), Path::exe()
Lexicalparent(), join(part), fileName(), extension(), stripExtension(), normalize(), isAbsolute(), toString()
Queryexists(), isFile(), isDirectory(), size(), lastModified()
ContentreadText(), writeText(s), appendText(s)
FS opsmkdir(), mkdirs(), delete(), rename(to), copy(to)

Lexical operations accept both / and \ separators and emit /.

Patterns

Atomic write through a temp file

use std::io::path::Path;

fn writeAtomic(path : string, content : string) : void ! IOException {
    let tmp : Path = Path::from(path + ".tmp");
    tmp.writeText(content);
    tmp.rename(path);                           // rename over the target
}

Writing to a temp file and renaming is the standard “don’t leave a half-written file” pattern.

Read-process-write

use std::io::path::Path;

fn transformText(raw : string) : string {
    return raw.toUpperCase();
}

fn transform(src : string, dst : string) : void ! IOException {
    let raw : string = Path::from(src).readText();
    let result : string = transformText(raw);
    Path::from(dst).writeText(result);
}

Path::readText / writeText open, transfer, and close in one call — no defer plumbing for the whole-file case.

Common pitfalls

Forgetting defer close() on a stream

use std::io::FileInputStream;

fn leak(path : string) : i32 ! IOException, FileNotFoundException {
    let input : FileInputStream = new FileInputStream(path);
    return input.read();                        // E0511 — no close() on this path
}

You can’t actually forget: a Closeable resource with no visible close() is rejected at compile time with E0511, not leaked at runtime. Pair the open with a defer close():

use std::io::FileInputStream;

fn ok(path : string) : i32 ! IOException, FileNotFoundException {
    let input : FileInputStream = new FileInputStream(path);
    defer input.close();
    return input.read();
}

(The whole-file Path::readText / writeText helpers close their own handle, so they need no defer.)

Catching IOException and continuing

use std::io::path::Path;

fn loadText(path : string) : string ! IOException {
    return Path::from(path).readText();
}

fn fragile() : string {
    try {
        return loadText("/etc/missing");
    } catch e : IOException {
        return "";                              // looks safe …
    }
}

"" is a poor sentinel — distinguishes neither “file empty” from “file missing”. Either propagate the exception or hand back a named value record. A struct is the right shape here: plain data with no identity, copied by value, built from a literal — no constructor boilerplate.

use std::io::path::Path;

fn loadText(path : string) : string ! IOException {
    return Path::from(path).readText();
}

struct LoadResult {
    ok      : bool;
    content : string;
}

fn tryLoad(path : string) : LoadResult {
    try {
        return LoadResult { ok: true, content: loadText(path) };
    } catch e : IOException {
        return LoadResult { ok: false, content: "" };   // miss is explicit in `ok`
    }
}

The caller reads result.ok to tell a real miss from an empty file, instead of overloading "" to mean both.

Using readText on unbounded input

use std::io::path::Path;

fn dangerous(path : string) : string ! IOException {
    return Path::from(path).readText();         // OK for config files ; not for logs
}

readText pulls the whole file into a string. For files larger than RAM, switch to chunked / line-by-line reads.

See also

iofilesfilesystemstreams