⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1516+8a4b5424dWhat's newOn an older Zig?

The Read-Eval-Print Loop

Read a line. Work out what it means. Print the result. Go back to the start. That is the loop every shell, every database prompt and every language REPL runs, and it is four lines of code.

The part that is not obvious is how it ends. There are two ways, they are completely different, and a loop that handles only one of them is broken in a way that is easy to ship.

The first is the user asking: they type quit. The second is the input running out. On a terminal that is Ctrl-D, and in a pipeline it is simply the end of the file, which is what happens every single time somebody writes echo "help" | yourshell. A loop that watches for quit and nothing else spins forever the first time it meets end of input, because the read keeps returning nothing and nothing is not quit.

The program

const std = @import("std");

const Action = enum { printed, quit };

/// The "eval" half. Kept separate from the loop so it can be tested without a
/// terminal, which is also why this page can run at all.
fn eval(line: []const u8, out: *std.Io.Writer) !Action {
    const trimmed = std.mem.trim(u8, line, " \t\r");

    if (trimmed.len == 0) return .printed; // a bare newline is not an error
    if (std.mem.eql(u8, trimmed, "quit")) return .quit;

    if (std.mem.startsWith(u8, trimmed, "echo ")) {
        try out.print("{s}\n", .{trimmed["echo ".len..]});
    } else if (std.mem.eql(u8, trimmed, "help")) {
        try out.writeAll("echo <text> | help | quit\n");
    } else {
        // Unknown input is not a reason to exit. A REPL that dies on a typo
        // is a REPL nobody uses.
        try out.print("unknown command: {s}\n", .{trimmed});
    }
    return .printed;
}

/// The loop. Two exits: the user asks to stop, or the input runs out.
fn repl(input: []const u8, out: *std.Io.Writer) !void {
    var reader: std.Io.Reader = .fixed(input);

    while (true) {
        try out.writeAll("> ");

        // takeDelimiter returns null at the end of input, which is EOF. On a
        // terminal that is Ctrl-D, and a loop that only checks for "quit"
        // spins forever the first time someone presses it.
        const line = try reader.takeDelimiter('\n') orelse {
            try out.writeAll("\n(end of input)\n");
            return;
        };

        try out.print("{s}\n", .{line});
        switch (try eval(line, out)) {
            .quit => {
                try out.writeAll("(bye)\n");
                return;
            },
            .printed => {},
        }
    }
}

pub fn main(init: std.process.Init) !void {
    var buf: [1024]u8 = undefined;
    var stdout_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &stdout_writer.interface;

    try out.writeAll("--- session that quits\n");
    try repl(
        \\help
        \\echo hello there
        \\
        \\nonsense
        \\quit
        \\
    , out);

    try out.writeAll("\n--- session that hits end of input\n");
    try repl("echo still here\n", out);

    try out.flush();
}

What just happened

Two sessions, two exits. The first ended on quit and printed (bye). The second ran out of input and printed (end of input). Both are normal endings, neither is an error, and the code paths are different.

The empty line did nothing and did not stop the loop. Pressing enter on a blank prompt is not a command and not an error, and a REPL that treats it as either is annoying within about four keystrokes.

An unknown command printed a message and carried on. This is the difference between a REPL and a batch program. A batch program that meets bad input should usually stop; a REPL that exits on a typo is unusable. Same input, opposite correct behaviour, decided entirely by whether a human is waiting.

eval is a separate function from the loop. That is what makes this page possible at all: the loop takes a reader, so the demo hands it a string instead of a terminal, and nothing in eval knows the difference. It is also how you test a REPL, which is otherwise one of the more annoying things to test.

Check yourself

The demo prints > and then echoes the line it just read. A real terminal does not do that. Why not, and why does this one have to?

Because a terminal echoes what you type as you type it: the characters appear because the terminal driver puts them there, not because the program printed them. This page has no keyboard, so the input is a string and nothing would show it. Echoing the line makes the transcript readable.

That difference matters in real programs too. A password prompt has to turn the terminal’s echo off before reading, which is a call into the terminal driver rather than anything to do with the read itself, and it is why reading a password is not just reading a line.

If you have written C

The loop is the same, and the ending is exactly where C makes it easy to get wrong:

while (1) {
    write(1, "> ", 2);
    ssize_t n = read(0, buf, sizeof buf - 1);
    if (n <= 0) break;          /* forget this and Ctrl-D spins forever */
    buf[n] = '\0';
    ...
}

read returning 0 is end of input, and it is not an error, so a check for n < 0 alone passes straight over it and loops forever on an empty read. That is the bug this chapter exists to name, and it is a rite of passage for anyone who has written a shell.

The sizeof buf - 1 is the same off-by-one from the strings chapter, leaving room for the terminator that read does not write.

Where this section ends

Three primitives: measure and copy text, split a line into arguments, and loop until something stops you. That is everything a shell needs before it can run a program.

What comes next needs a real kernel, so it lives in the operating system section rather than here: spawning a process is the fork and exec pair, and pipes is how the output of one becomes the input of another. Neither can run on this page, and both say so.