How the lexer works
The lexer is the first stage of the compiler. It turns a string of characters into a list of tokens, the words of the language. Every later stage works with tokens and never looks at raw characters again.
let count = 3 -> Let Ident("count") Assign Int(3) Newline Eof
Try it on any file:
cargo run -- tokens examples/hello.zeno
The files
src/token.rs: the list of token kinds, and the keyword table.src/lexer.rs: the lexer and its tests.
The main loop
Lexer::run is one loop. Each time round it looks at the next character,
without consuming it, and that one character is enough to decide what kind of
token starts here:
| Next character | What it starts | Handled by |
|---|---|---|
| space, tab | nothing, skip it | |
| line break | maybe the end of a statement | end_statement |
# |
a comment, skipped to the end of the line | comment |
" or ' |
a string | string |
f followed by a quote |
a formatted string | string |
| a digit | a number | number |
a letter or _ |
a keyword or a name | word |
| anything else | punctuation or an operator | symbol |
Each handler consumes exactly the characters of its token and pushes one token. Then the loop goes round again.
Three small helpers do all the reading: peek looks at the next character,
advance consumes it (and keeps the line and column counters right), and
eat(c) consumes it only if it is c. Two-character operators fall out of
eat: after reading =, eat('=') tells you whether it was == or =.
Keywords are just names in a table
word reads letters, digits and underscores, then asks keyword() whether the
result is reserved. function becomes the Function token; functions is an
ordinary name. Adding a keyword to Zeno is one line in the enum and one in the table.
Line breaks instead of semicolons
This is the only subtle part. A line break ends a statement, so the lexer
emits a Newline token for it. But not every line break ends a statement, so
end_statement stays quiet in three cases:
- Inside
( )or[ ]. The lexer counts open brackets indepth. While it is above zero, line breaks are ignored, so long calls and lists can wrap. A{starts counting afresh, because inside braces there are statements again, even when the braces sit inside a call:items.map(function(n) { ... }). - After a token that cannot end a statement, such as
+,,,=orand(seeTokenKind::continues_line). The statement carries on to the next line. - After another
Newline, or at the top of the file. Blank lines and comment-only lines produce nothing.
The parser therefore sees exactly one Newline per statement and never has to
think about blank lines.
Errors
The lexer does not stop at the first problem. It records each one with its line and column, skips the bad character, and carries on, so you see every mistake in one run. Habits from other languages get a hint:
error: Zeno has no semicolons. A line break ends a statement
--> main.zeno:1:10
|
1 | let x = 1;
| ^
What the lexer leaves for later
- Formatted strings.
f"Hello, {who}"becomes one string token markedformatted. Splitting out the{who}part is the parser's job. - Units.
500 msis two tokens, a number and a name. The parser joins them. - Negative numbers.
-5isMinusthenInt(5). - Meaning. The lexer is happy with
let let = = 3. Deciding that it makes no sense is the parser's job.
Things to try
Good exercises for getting to know the code:
- Add a
%=operator. (One enum line, one branch insymbol, one test.) - Add an
unlesskeyword. - Allow multi-line strings in triple quotes,
"""like this""". - Make
0xwith no digits after it say "expected digits after 0x".
This page is docs/lexer.md in the language repository.