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

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.