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.