A Lexer
A compiler is not one program that understands your source. It is a short pipeline of programs, each of which takes the output of the last and knows almost nothing about the others. This section builds that pipeline, and the first stage is the one that turns characters into words.
Source code arrives as bytes. let total = 0; is fourteen of them, and nothing
in there says that let is a keyword or that total is one name rather than
five letters. Deciding that is lexing, and doing it as a separate pass is
the decision that makes everything downstream tractable: the parser never sees
a space, never counts a digit, and never wonders whether letter starts with
the keyword let.
The program
const std = @import("std");
pub const Kind = enum {
number,
identifier,
plus,
minus,
star,
slash,
lparen,
rparen,
lbrace,
rbrace,
semi,
comma,
assign,
eq,
lt,
kw_let,
kw_print,
kw_if,
kw_else,
kw_while,
kw_fn,
kw_return,
eof,
invalid,
};
pub const Token = struct {
kind: Kind,
/// Points into the source. The token owns nothing.
text: []const u8,
value: i64 = 0,
line: usize = 1,
};
/// The words that are not identifiers. Checked after scanning a word, which is
/// why `letter` lexes as an identifier and not as `let` followed by `ter`:
/// the scan is greedy first, and the lookup happens on the whole word.
fn keyword(word: []const u8) ?Kind {
const table = .{
.{ "let", Kind.kw_let },
.{ "print", Kind.kw_print },
.{ "if", Kind.kw_if },
.{ "else", Kind.kw_else },
.{ "while", Kind.kw_while },
.{ "fn", Kind.kw_fn },
.{ "return", Kind.kw_return },
};
inline for (table) |entry| {
if (std.mem.eql(u8, word, entry[0])) return entry[1];
}
return null;
}
fn isDigit(c: u8) bool {
return c >= '0' and c <= '9';
}
fn isAlpha(c: u8) bool {
return (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or c == '_';
}
/// Fill `out` with the tokens of `source`, returning the part used.
pub fn tokenize(source: []const u8, out: []Token) ![]Token {
var i: usize = 0;
var line: usize = 1;
var n: usize = 0;
while (i < source.len) {
const c = source[i];
// Whitespace carries no meaning beyond separating tokens, so it is
// dropped here and the parser never has to think about it again.
if (c == '\n') {
line += 1;
i += 1;
continue;
}
if (c == ' ' or c == '\t' or c == '\r') {
i += 1;
continue;
}
if (n == out.len) return error.TooManyTokens;
const start = i;
if (isDigit(c)) {
var value: i64 = 0;
while (i < source.len and isDigit(source[i])) : (i += 1) {
value = value * 10 + (source[i] - '0');
}
out[n] = .{ .kind = .number, .text = source[start..i], .value = value, .line = line };
} else if (isAlpha(c)) {
while (i < source.len and (isAlpha(source[i]) or isDigit(source[i]))) : (i += 1) {}
const word = source[start..i];
out[n] = .{ .kind = keyword(word) orelse .identifier, .text = word, .line = line };
} else {
i += 1;
// The only place lookahead is needed: `=` and `==` start the same.
const kind: Kind = switch (c) {
'+' => .plus,
'-' => .minus,
'*' => .star,
'/' => .slash,
'(' => .lparen,
')' => .rparen,
'{' => .lbrace,
'}' => .rbrace,
';' => .semi,
',' => .comma,
'<' => .lt,
'=' => blk: {
if (i < source.len and source[i] == '=') {
i += 1;
break :blk .eq;
}
break :blk .assign;
},
else => .invalid,
};
out[n] = .{ .kind = kind, .text = source[start..i], .line = line };
}
n += 1;
}
if (n == out.len) return error.TooManyTokens;
out[n] = .{ .kind = .eof, .text = "", .line = line };
return out[0 .. n + 1];
}
pub fn main(init: std.process.Init) !void {
var buf: [2048]u8 = undefined;
var stdout_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &stdout_writer.interface;
const source =
\\let total = 0;
\\while (total < 10) {
\\ total = total + 3;
\\}
\\print total;
;
var storage: [128]Token = undefined;
const tokens = try tokenize(source, &storage);
try out.print("{d} tokens\n\n", .{tokens.len});
for (tokens) |token| {
try out.print("line {d} {t: <12} \"{s}\"", .{ token.line, token.kind, token.text });
if (token.kind == .number) try out.print(" value {d}", .{token.value});
try out.writeAll("\n");
}
try out.flush();
}What just happened
Whitespace vanished, and that is the point. The parser in the next chapter will never handle a space, a tab, or a newline, because they are consumed here and produce no token. Every stage after this one is simpler for it. The line number survives, though, attached to each token, because an error message without a line number is nearly useless and this is the only stage that still knows.
total came out as one token, not five. The scan is greedy: once a letter
starts a word, it keeps taking letters and digits until it meets something
else. That single rule is why total3 is one identifier and why 3total is a
number followed by an identifier, which the parser will reject as it should.
Keywords are identifiers that lost a lookup. The scanner does not try to
match let up front. It scans the whole word and then checks the table, which
is the only order that works: matching let eagerly would lex letter as
let followed by ter. Every language with keywords does it this way.
= and == are why lookahead exists. After consuming =, the scanner
peeks at the next character before deciding which token it made. One character
is enough for this language, and one character is enough for most. When a
language needs more, that is a real design cost, and it is why >> in old C++
generic syntax was such a famous problem.
Nothing was allocated. Every token’s text is a slice pointing back into
the source, so the tokens are meaningless once the source goes away, and
producing them copied nothing. That is the same borrowing decision as
sort, and it is worth noticing that a compiler makes
it too.
Check yourself
The lexer never rejects let let = 3; or 4 5 6. Should it?
No, and the split is deliberate. A lexer’s job is to say what the words are, not whether the sentence makes sense. Both of those inputs are perfectly valid token streams, and both are errors, and it is the parser that will say so. Two stages with a clean boundary beat one stage that does both jobs and cannot explain which one failed.
If you have written C
The C version of this is a switch on the current character inside a while,
with an enum of token types and a struct holding a pointer, a length and a
line number, which is exactly what is above. The differences are small and
worth naming.
C’s token would hold char *start; int len; as two fields you keep in step by
hand. A Zig slice is those two fields with the language enforcing that they
travel together.
And C’s keyword lookup is usually a chain of strcmp calls. The version here
uses a comptime inline for over a table, so the comparisons are generated but
the table is written once. Neither is a hash, and neither needs to be at seven
keywords.
Next: a parser, which takes this flat list and finds the structure in it.