Intermediate

I/O Operations in Hare

Learn console output, reading standard input, and file reading and writing in Hare, with tagged-union error handling and Docker-ready examples

Input and output are where a systems language earns its keep. Hare handles I/O through a small set of standard library modules — fmt for formatted text, io for the low-level handle interface, os for process streams and files, and bufio for buffered line-oriented reading. Everything flows through the io::handle abstraction, so the same functions that write to the console also write to a file.

As an imperative systems language, Hare gives you direct, explicit control over I/O with no hidden buffering or allocation. What makes Hare distinctive is that errors cannot be silently ignored. Every operation that can fail returns a tagged union that includes an error type, and the compiler forces you to acknowledge it — either by asserting success with the ! operator or by handling the error with match. This tutorial covers console output, reading from standard input, and reading and writing files, all while respecting Hare’s error-handling discipline.

You have already seen fmt::println in Hello World. Here we go further: formatted output with placeholders and base conversions, buffered input, and the os/io file APIs.

Console Output

Hare’s fmt module provides a family of output functions. The f-prefixed variants (fprint, fprintln) write to a specific handle; the printf/printfln variants interpret a format string with {} placeholders.

Create a file named io_operations.ha:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
use fmt;
use os;

export fn main() void = {
	// println adds a trailing newline; print does not
	fmt::println("Formatted output in Hare")!;
	fmt::print("A")!;
	fmt::print("B")!;
	fmt::println("C")!;

	// printfln substitutes {} placeholders in order
	const language = "Hare";
	const year = 2022;
	fmt::printfln("{} first appeared in {}", language, year)!;

	// Placeholders can reference arguments by index and reuse them
	fmt::printfln("{0} {0} {1}", "echo", "!")!;

	// Base-conversion modifiers after a colon
	fmt::printfln("255 in hex is {:x}", 255)!;
	fmt::printfln("255 in octal is {:o}", 255)!;
	fmt::printfln("255 in binary is {:b}", 255)!;

	// fprintln targets a specific handle — here, standard error
	fmt::fprintln(os::stderr, "This message goes to stderr")!;
};

Note that os::stderr is passed as an ordinary argument. It has type io::file, which satisfies the io::handle interface — the same interface a file handle satisfies, as you will see below.

Reading Input from Standard Input

To read from the terminal (or piped input), wrap os::stdin in a buffered scanner from the bufio module. The scanner reads efficiently and hands you one line at a time with the trailing newline stripped. bufio::scan_line returns a tagged union of (const str | io::EOF | io::error), forcing you to handle end-of-input and read errors explicitly.

Create a file named read_input.ha:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use bufio;
use fmt;
use io;
use os;

export fn main() void = {
	fmt::print("Enter your name: ")!;

	// A scanner buffers reads from any handle; finish() frees its buffer
	const scan = bufio::newscanner(os::stdin);
	defer bufio::finish(&scan);

	const name = match (bufio::scan_line(&scan)) {
	case io::EOF =>
		fmt::println()!;
		return;
	case let err: io::error =>
		fmt::fatalf("Input error: {}", io::strerror(err));
	case let line: const str =>
		yield line;
	};

	fmt::printfln("Hello, {}!", name)!;
};

The match expression has three arms: io::EOF (no input), io::error (a genuine read failure, reported with fmt::fatalf, which prints to stderr and exits), and the success case, which yields the line as the value of the match. Because the error and EOF arms terminate, the compiler knows name always ends up being a str.

Writing to a File

os::create opens a new file for writing, truncating any existing file, and returns an io::file. Because that handle satisfies io::handle, the very same fmt::fprintln and fmt::fprintfln functions write to it. The defer statement guarantees the file is closed when main returns, even on an early exit.

Create a file named file_write.ha:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
use fmt;
use io;
use os;
use strings;

export fn main() void = {
	// 0o644 is the Unix permission mode (rw-r--r--)
	const file = os::create("output.txt", 0o644)!;
	defer io::close(file)!;

	// fprintln and fprintfln write formatted text to the handle
	fmt::fprintln(file, "Line 1: written with fprintln")!;
	fmt::fprintfln(file, "Line 2: {} times {} is {}", 6, 7, 6 * 7)!;

	// io::writeall writes raw bytes; strings::toutf8 exposes a string's bytes
	const bytes = strings::toutf8("Line 3: written with writeall\n");
	const n = io::writeall(file, bytes)!;

	fmt::printfln("Wrote output.txt ({} bytes on the final write)", n)!;
};

This shows two levels of abstraction: the convenient fmt functions for formatted text, and io::writeall for writing an exact byte slice — useful when you already have binary data or a prepared buffer.

Reading from a File

Reading a file combines the pieces above: open a handle with os::open, wrap it in a scanner, and loop until io::EOF. This example is self-contained — it first writes a small file, then reads it back line by line — so it produces the same output every time.

Create a file named file_read.ha:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
use bufio;
use fmt;
use io;
use os;

export fn main() void = {
	// Create a small file to read back
	const out = os::create("data.txt", 0o644)!;
	fmt::fprintln(out, "apple")!;
	fmt::fprintln(out, "banana")!;
	fmt::fprintln(out, "cherry")!;
	io::close(out)!;

	// os::open opens an existing file read-only by default
	const file = os::open("data.txt")!;
	defer io::close(file)!;

	const scan = bufio::newscanner(file);
	defer bufio::finish(&scan);

	let lineno = 1;
	for (true) {
		const line = match (bufio::scan_line(&scan)) {
		case io::EOF =>
			break;
		case let err: io::error =>
			fmt::fatalf("Read error: {}", io::strerror(err));
		case let line: const str =>
			yield line;
		};
		fmt::printfln("{}: {}", lineno, line)!;
		lineno += 1;
	};
};

The for (true) loop runs until the io::EOF arm executes break. Notice that the same bufio::newscanner / bufio::scan_line pattern reads a file here just as it read the terminal in the previous example — a handle is a handle.

Running with Docker

Alpine Linux’s edge repository packages Hare, so no local install is required. Each command mounts the current directory and installs the compiler before running.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Pull the Alpine edge image
docker pull alpine:edge

# Console output example
docker run --rm -v $(pwd):/code alpine:edge sh -c "apk add --no-cache hare > /dev/null 2>&1 && cd /code && hare run io_operations.ha"

# Reading input — pipe a value and use -i to keep stdin open
echo "Ada" | docker run --rm -i -v $(pwd):/code alpine:edge sh -c "apk add --no-cache hare > /dev/null 2>&1 && cd /code && hare run read_input.ha"

# Writing to a file
docker run --rm -v $(pwd):/code alpine:edge sh -c "apk add --no-cache hare > /dev/null 2>&1 && cd /code && hare run file_write.ha"

# Reading from a file
docker run --rm -v $(pwd):/code alpine:edge sh -c "apk add --no-cache hare > /dev/null 2>&1 && cd /code && hare run file_read.ha"

Expected Output

Running io_operations.ha (the final line is written to stderr):

Formatted output in Hare
ABC
Hare first appeared in 2022
echo echo !
255 in hex is ff
255 in octal is 377
255 in binary is 11111111
This message goes to stderr

Running read_input.ha with Ada piped in (the prompt has no newline, so the reply continues on the same line):

Enter your name: Hello, Ada!

Running file_write.ha:

Wrote output.txt (30 bytes on the final write)

The resulting output.txt contains:

Line 1: written with fprintln
Line 2: 6 times 7 is 42
Line 3: written with writeall

Running file_read.ha:

1: apple
2: banana
3: cherry

Key Concepts

  • One handle interfaceos::stdout, os::stderr, os::stdin, and files all satisfy io::handle, so fmt::fprintln, io::writeall, and the bufio scanner work uniformly across the console and files.
  • Errors are unavoidable — I/O functions return tagged unions containing an error type. Use ! to assert success (aborting on failure) or match to handle each case explicitly.
  • match on io::EOF — Reading loops branch on (const str | io::EOF | io::error); the io::EOF arm is how you detect the end of input.
  • Buffered reading with bufiobufio::newscanner wraps a handle for efficient line-by-line reads; pair it with defer bufio::finish(&scan) to free the buffer.
  • Format placeholders{} substitutes arguments in order, {0} references by index (and can repeat), and modifiers like {:x}, {:o}, and {:b} convert integers to hexadecimal, octal, and binary.
  • defer for cleanupdefer io::close(file)! runs when the function returns, keeping resource release next to acquisition and correct even on early exits.
  • Two write levelsfmt::fprintln/fmt::fprintfln for formatted text, io::writeall with strings::toutf8 for exact byte control.

Running Today

All examples can be run using Docker:

docker pull alpine:edge
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining