Lesson 1.10 — Lossless lexing with trivia, and incremental relexing for IDEs¶
Techniques: tokens with attached trivia (Roslyn, SwiftSyntax, Pebble's
LeadingTrivia), trivia as tokens (rustc_lexer, rust-analyzer's rowan trees), incremental relexing (tree-sitter; Wagner–Graham) · Pebble implements: lossless tokens with leading trivia (E5); the unit testPebbleLexer.CorpusIsLosslesschecks the round trip on every corpus file and 3000 random inputs · Prerequisites: Lessons 1.5, 1.7 · Time: 3–4 hours
A batch compiler throws whitespace and comments away. An IDE, a formatter, a refactoring tool or a documentation generator cannot: renaming a variable must preserve every comment, clang-format must know where the blank lines were, and an editor must re-lex after every keystroke without re-reading a 10 000-line file. This lesson covers the two properties those tools need from a lexer: losslessness (the token stream reproduces the file byte for byte) and incrementality (after an edit, only the tokens near the edit are recomputed).
1. Problem and motivation¶
Input: a file, and later a sequence of edits. Output: a token stream from which the exact source text can be rebuilt, updated after each edit in time proportional to the change, not the file.
Tokens with attached trivia¶
Roslyn (C#) and SwiftSyntax attach whitespace and comments ("trivia") to the neighboring tokens as leading and trailing trivia, so that every byte of the file belongs to exactly one token and a syntax tree prints back to its source [ROSLYN-Lexer, SWIFTSYNTAX-Trivia]. Pebble's Token::LeadingTrivia is the minimal version: a byte count of the whitespace and comments before each token, with the eof token carrying the trailing trivia of the file.
Trivia as tokens¶
The alternative is to make whitespace and comments ordinary tokens. rustc's lexer crate, rustc_lexer, returns Whitespace, LineComment and BlockComment tokens with their lengths, and rust-analyzer builds lossless syntax trees ("green trees", the rowan library) in which these tokens are leaves [RA-LexedStr]. Parsers skip them; tools see everything.
Incremental relexing¶
An editor edits a few bytes; relexing the file from the start costs time proportional to its size, on every keystroke. Wagner and Graham [WG98] showed how to relex and reparse incrementally: find the first token the edit can have affected, relex from there, and stop as soon as the new token stream falls back into step with the old one. tree-sitter [TS-Parser] does exactly this for every language it supports, and editors from Neovim to GitHub's code view use it.
2. Definitions and algorithms¶
Definition 1.10.1 (Lossless token stream)
A token stream \(t_1, \dots, t_m\) (the last being eof) for a source \(w\) is lossless if each token has a spelling \(s_i\) and trivia \(\tau_i\) (leading), or trivia split as \(\lambda_i\) (leading) and \(\rho_i\) (trailing), such that the concatenation reproduces the source:
Pebble stores \(\tau_i\) implicitly: Token::LeadingTrivia \(= \lvert \tau_i \rvert\) and the bytes are the ones just before Token::Loc.
Definition 1.10.2 (Roslyn/SwiftSyntax trivia attachment)
Given the trivia run \(\theta\) between tokens \(t_i\) and \(t_{i+1}\): the trailing trivia of \(t_i\) is the longest prefix of \(\theta\) that contains no line break, plus the first line break if there is one; the leading trivia of \(t_{i+1}\) is the rest. The file's first token takes all trivia before it as leading trivia; eof takes the rest.
Definition 1.10.3 (Trivia tokens and green trees)
In a trivia-as-tokens stream, whitespace and comments are tokens of kinds WHITESPACE, COMMENT; the parser's token cursor skips them, and the syntax tree builder attaches them as leaves between the nodes. A green tree (rowan) is an immutable tree whose nodes store their kind and total text length but not their absolute position, so equal subtrees can be shared and reused after an edit.
Definition 1.10.4 (Edit, lookahead extent)
An edit replaces the bytes \([a, b)\) of \(w\) by a string \(x\), giving \(w'\); its shift is \(\Delta = \lvert x \rvert - (b - a)\). The lookahead extent of a token is the index one past the last byte the lexer examined while producing it, trivia included (for a maximal-munch scanner: up to and including the byte that led to the dead state, Definition 1.6.4). The first affected token is the first token whose lookahead extent is greater than \(a\): the first token that examined a byte at or after the edit. A token boundary is the position just after a token (where the next token's leading trivia begins).
Definition 1.10.5 (Lexer state)
The lexer state at a token boundary is everything the next lex() call depends on besides the input bytes: for Pebble, the interpolation stack (Definition 1.7.2); for Python, the indentation stack and the bracket depth; for a pure DFA lexer, nothing.
Tokens with attached trivia¶
Algorithm 1.10.6 (Leading-trivia lexing and Roslyn-style splitting)
- Input: the source \(w\); a lexer that skips trivia before each token.
- Output: tokens with \(\lvert\tau_i\rvert\) (Pebble), or with \((\lambda_i, \rho_i)\) (Roslyn style).
- Precondition: every byte is either trivia or part of a token (error bytes become
unknowntokens, never silently dropped). - Postcondition: the stream is lossless (Theorem 1.10.9).
- Invariant:
cursor= the number of bytes accounted for by the tokens and trivia emitted so far.
function LexLossless(w): # Pebble
cursor ← 0; out ← []
loop:
triviaStart ← cursor; cursor ← SkipTrivia(w, cursor)
(kind, end) ← ScanToken(w, cursor) # end = cursor for eof
append Token{kind, loc: cursor, spelling: w[cursor..end), leadingTrivia: cursor − triviaStart}
cursor ← end
if kind = eof: return out
function SplitTrivia(theta): # Roslyn/SwiftSyntax (Definition 1.10.2)
k ← index of the first line break in theta, or |theta| if none
trailing ← theta[0 .. k+1) if k < |theta| else theta
leading ← theta[|trailing| ..)
return (trailing, leading)
Pebble keeps a trivia count per token; Clang keeps only two flags
Reproduce (ch01-lexdump built from this repository with -DPEBBLE_USE_SOLUTION=lexer, clang 23.1.2):
printf 'let x = 1; // one\n /* two */ y\n' > triv.pbl
ch01-lexdump --trivia triv.pbl
printf 'int x = 1; // one\n /* two */ int y;\n' > triv.c
clang-23 -fsyntax-only -Xclang -dump-tokens triv.c 2>&1
Output (complete):
1:1 kw_let 'let' trivia=0
1:5 identifier 'x' trivia=1
1:7 equal '=' trivia=1
1:9 int_literal '1' = 1 trivia=1
1:10 semi ';' trivia=0
2:13 identifier 'y' trivia=20
3:1 eof '' trivia=1
int 'int' Loc=<triv.c:1:1> [StartOfLine]
identifier 'x' Loc=<triv.c:1:5> [LeadingSpace]
equal '=' Loc=<triv.c:1:7> [LeadingSpace]
numeric_constant '1' Loc=<triv.c:1:9> [LeadingSpace]
semi ';' Loc=<triv.c:1:10>
int 'int' Loc=<triv.c:2:13> [StartOfLine] [LeadingSpace]
identifier 'y' Loc=<triv.c:2:17> [LeadingSpace]
semi ';' Loc=<triv.c:2:18>
eof '' Loc=<triv.c:2:19>
What to notice: y carries trivia=20: the 20 bytes // one\n /* two */ before it, so Pebble's stream is lossless (Definition 1.10.1) and a tool can recover the comments from the source buffer. Clang keeps only the flags [StartOfLine] and [LeadingSpace] per token (enough for -E output and some diagnostics); clang-format and other tools re-lex the buffer themselves in a raw mode that keeps comments as tokens.
Trivia as tokens¶
Algorithm 1.10.7 (Trivia tokens with a skipping cursor)
- Input: \(w\); a lexer that returns trivia as tokens (Definition 1.10.3).
- Output: the full token list, and a parser cursor over non-trivia tokens.
- Precondition: every byte belongs to exactly one token (unknown bytes included).
- Postcondition: concatenating all token texts gives \(w\) (Theorem 1.10.9); the parser sees the same tokens as a trivia-skipping lexer.
- Invariant: the sum of the lengths of the tokens returned so far is the current offset.
function Tokenize(w):
pos ← 0; toks ← []
while pos < |w|:
(kind, len) ← AdvanceToken(w, pos) # rustc_lexer::Cursor::advance_token
append (kind, len) to toks; pos ← pos + len
return toks
function ParserCursor(toks): # rust-analyzer: skip trivia, remember them
i ← 0
next(): while toks[i].kind ∈ {WHITESPACE, COMMENT}: i ← i + 1
return toks[i++]
# the tree builder re-inserts every skipped trivia token as a leaf of the green tree
rustc's lexer returns whitespace and comments as tokens
Reproduce (rustc 1.94.1, cargo 1.94.1; ra-ap-rustc_lexer 0.174.0 is rustc_lexer as published from rust-lang/rust; unicode-ident pinned to 1.0.24 to match its Unicode version):
cargo new --quiet ratrivia && cd ratrivia
cargo add ra-ap-rustc_lexer@=0.174.0
cargo update -q -p unicode-ident --precise 1.0.24
cat > src/main.rs <<'EOF'
use ra_ap_rustc_lexer::{tokenize, FrontmatterAllowed};
fn main() {
let src = "let x = 1; // one\nlet s = r#\"a \"quoted\" b\"#;/* c /* nested */ */\n";
let mut pos = 0;
for tok in tokenize(src, FrontmatterAllowed::No) {
let len = tok.len as usize;
println!("{:>3}..{:<3} {:?} {:?}", pos, pos + len, tok.kind, &src[pos..pos + len]);
pos += len;
}
}
EOF
cargo run -q
Output (complete):
0..3 Ident "let"
3..4 Whitespace " "
4..5 Ident "x"
5..6 Whitespace " "
6..7 Eq "="
7..8 Whitespace " "
8..9 Literal { kind: Int { base: Decimal, empty_int: false }, suffix_start: 1 } "1"
9..10 Semi ";"
10..11 Whitespace " "
11..17 LineComment { doc_style: None } "// one"
17..18 Whitespace "\n"
18..21 Ident "let"
21..22 Whitespace " "
22..23 Ident "s"
23..24 Whitespace " "
24..25 Eq "="
25..26 Whitespace " "
26..43 Literal { kind: RawStr { n_hashes: Some(1) }, suffix_start: 17 } "r#\"a \"quoted\" b\"#"
43..44 Semi ";"
44..64 BlockComment { doc_style: None, terminated: true } "/* c /* nested */ */"
64..65 Whitespace "\n"
What to notice: the token lengths tile the input from 0 to 65 with no gaps (Algorithm 1.10.7's invariant): whitespace and comments are tokens. Keywords are not recognized here (let is Ident): rustc classifies them later through its symbol table (Lesson 1.8). The raw string records n_hashes: Some(1) (Lesson 1.7) and the nested block comment is one token with terminated: true, the same nesting rule as Pebble.
Incremental relexing¶
Algorithm 1.10.8 (Incremental relexing, Wagner–Graham style)
- Input: the old token list (with start, length, lookahead extent and lexer state at each boundary); an edit \(([a, b), x)\) turning \(w\) into \(w'\).
- Output: the new token list.
- Precondition: the lexer is deterministic: its output from a boundary depends only on the bytes from there and the lexer state (Definition 1.10.5).
- Postcondition: the result equals a full relex of \(w'\) (Theorem 1.10.10).
- Invariant: the new tokens emitted so far are exactly the full relex of \(w'\) up to the current position.
function Relex(old, a, b, x):
Δ ← |x| − (b − a)
k ← index of the first old token whose lookahead extent > a # it examined a changed byte
pos ← boundary before old[k]; state ← lexer state at that boundary
new ← old[0 .. k) # unchanged prefix, reused
j ← k # candidate old token to resync after
loop:
(tok, state) ← LexOne(w', pos, state) # trivia + one token
append tok to new; pos ← tok.end
if tok = eof: return new
if pos ≥ b + Δ: # past the edited region
while j < |old| and old[j].end + Δ < pos: j ← j + 1
if j < |old| and old[j].end + Δ = pos and old[j].stateAfter = state:
return new ++ shift(old[j+1 ..], Δ) # back in step: reuse the rest
tree-sitter keeps the tokens as leaves of its syntax tree; ts_tree_edit shifts positions and marks the affected nodes, and the next parse re-lexes only where a reused leaf is not valid (ts_parser__can_reuse_first_leaf checks that the leaf's lookahead did not cross the edit).
tree-sitter reparses after an edit and reports the changed range
Reproduce (Python 3.11.15 with py-tree-sitter 0.26.0 and tree-sitter-python 0.25.0 from PyPI; any OS):
python3 -m venv venv && ./venv/bin/pip install -q tree-sitter==0.26.0 tree-sitter-python==0.25.0
cat > tsdemo.py <<'EOF'
import tree_sitter_python as tspython
from tree_sitter import Language, Parser
def leaves(node, out):
if node.child_count == 0:
out.append(f"{node.type}@{node.start_byte}-{node.end_byte}")
for child in node.children:
leaves(child, out)
return out
parser = Parser(Language(tspython.language()))
old_src = b"x = 1\ny = 'abc'\nz = x + y\n"
old = parser.parse(old_src)
print("before:", " ".join(leaves(old.root_node, [])))
# Insert one quote at byte 10: y = ''abc' -> the string now ends early
new_src = old_src[:10] + b"'" + old_src[10:]
old.edit(start_byte=10, old_end_byte=10, new_end_byte=11,
start_point=(1, 4), old_end_point=(1, 4), new_end_point=(1, 5))
new = parser.parse(new_src, old) # reuses the unchanged parts of `old`
print("after: ", " ".join(leaves(new.root_node, [])))
for r in old.changed_ranges(new):
print("changed range: bytes", r.start_byte, "to", r.end_byte)
EOF
./venv/bin/python tsdemo.py
Output (complete):
before: identifier@0-1 =@2-3 integer@4-5 identifier@6-7 =@8-9 string_start@10-11 string_content@11-14 string_end@14-15 identifier@16-17 =@18-19 identifier@20-21 +@22-23 identifier@24-25
after: identifier@0-1 =@2-3 integer@4-5 identifier@6-7 =@8-9 string_start@10-11 string_end@11-12 identifier@12-15 string_start@15-16 identifier@17-18 =@19-20 identifier@21-22 +@23-24 identifier@25-26
changed range: bytes 10 to 26
What to notice: the tokens before the edit (x = 1, y =) are reused unchanged. After the inserted quote, '' is an empty string, abc becomes an identifier and the old closing quote opens a new, unterminated string: one byte of edit changed the meaning of everything to the end of the file (changed range: bytes 10 to 26). Relexing cannot resynchronize while a string is open, which is why Pebble ends an unterminated string at the line break (E0108): the damage of a stray quote is then bounded by one line (unless the quote turns a \( into an interpolation that stays open, Proposition 1.10.11).
3. Worked example¶
Tokens with attached trivia¶
The Pebble input let x = 1; // one⏎ /* two */ y⏎ (32 bytes). Leading trivia (Pebble) and the Roslyn split (Definition 1.10.2):
| token | spelling | Pebble \(\lvert\tau\rvert\) | Roslyn leading | Roslyn trailing |
|---|---|---|---|---|
let |
let |
0 | – | ␣ |
x |
x |
1 | – | ␣ |
= |
= |
1 | – | ␣ |
1 |
1 |
1 | – | – |
; |
; |
0 | – | ␣// one⏎ |
y |
y |
20 | ␣␣/* two */␣ |
⏎ |
| eof | – | 1 | – | – |
Check: the spellings have \(3+1+1+1+1+1 = 8\) bytes and Pebble's trivia counts sum to \(0+1+1+1+0+20+1 = 24\), together the 32 bytes of the input; the Roslyn columns hold \(1+1+1+8+12+1 = 24\) trivia bytes too. In the Roslyn split the comment // one belongs to ; (same line) and /* two */ to y (next line).
Trivia as tokens¶
The same input with trivia tokens (rustc_lexer style): let, WS ␣, x, WS, =, WS, 1, ;, WS, COMMENT // one, WS ⏎␣␣, COMMENT /* two */, WS ␣, y, WS ⏎. The parser cursor skips the 8 trivia tokens and sees let x = 1 ; y, the same six tokens as Pebble's stream.
Incremental relexing¶
Old Pebble source let s = "ab";⏎let n = 1;⏎ (line 2 starts at byte 14; the lexer state, the interpolation stack, is empty at every boundary).
Edit 1: replace the 1 at byte 22 by 12 (\(a = 22\), \(b = 23\), \(\Delta = +1\)). The token = at 20 examined byte 21 (to rule out ==): extent 22, not greater than \(a\). The token 1 at 22 examined the ; at 23: extent 24 > 22, so it is the first affected token. Relex from its boundary (21): 12, ending at 24. The old token 1 ended at 23 and \(23 + \Delta = 24\), with the same (empty) state after it: resynchronized after one new token; ;, eof and everything else are reused, shifted by one.
Edit 2: insert " at byte 9, inside the string: line 1 becomes let s = ""ab";. The string token at 8 examined bytes up to 11 (extent 12 > 9): first affected. Relex: "" (8–10, a complete empty string), ab (10–12, an identifier), and a " at 12 that opens a string running to the line break: E0108, token "; (12–14). The old ; ended at 13, and \(13 + \Delta = 14\) = the new position, with the same empty state: resynchronized. Three new tokens and one diagnostic; line 2 is reused untouched. Without line-bounded string recovery the new string would run to the end of the file, as in the tree-sitter box.
4. Invariants and correctness¶
Tokens with attached trivia¶
Theorem 1.10.9 (Losslessness)
If every byte of \(w\) is either consumed by SkipTrivia or by ScanToken (the precondition of Algorithms 1.10.6 and 1.10.7), then the streams they produce are lossless (Definition 1.10.1), and SplitTrivia assigns every trivia byte to exactly one token.
Proof
By the invariant of Algorithm 1.10.6, after each token cursor equals the total length of the trivia and spellings emitted so far; each step appends exactly the bytes \([\mathit{triviaStart}, \mathit{cursor})\) as trivia and \([\mathit{cursor}, \mathit{end})\) as spelling, contiguous with the previous step. The loop ends at eof, whose position is \(\lvert w \rvert\), so the concatenation is \(w\). For SplitTrivia, trailing and leading are a partition of \(\theta\) into a prefix and a suffix. The same argument applies to trivia tokens (Algorithm 1.10.7). Pebble's unit test PebbleLexer.CorpusIsLossless checks the concatenation on every corpus file and RandomInputsAreLosslessAndTerminate on 3000 random inputs, including invalid UTF-8 and unterminated constructs. \(\square\)
Trivia as tokens¶
The correctness claim for trivia tokens is Theorem 1.10.9 (second part) together with the observation that a cursor that skips a fixed set of kinds presents the parser with exactly the non-trivia subsequence, which is the stream a trivia-skipping lexer produces when both lexers agree on token boundaries. The corpus comparison of Pebble's two lexers (Lesson 1.5's table lexer returns trivia tokens; TableLexer.AgreesWithTheHandWrittenPebbleLexer drops them and compares) is a mechanical check of that agreement.
Incremental relexing¶
Theorem 1.10.10 (Incremental relexing is exact)
Under the precondition of Algorithm 1.10.8, the returned list equals the full relex of \(w'\).
Proof sketch (full treatment: [WG98, §4])
Prefix: tokens \(0..k-1\) examined only bytes before \(a\) (their lookahead extents are \(\le a\)), which are unchanged, and they were produced from the same states; by determinism the full relex of \(w'\) produces them again. Middle: from token \(k\) on the algorithm is the full relex (it calls LexOne from the same position and state as the full relex would), so the invariant holds. Suffix: when the new stream reaches a boundary \(p \ge b + \Delta\) in state \(s\), and an old token boundary \(q\) (the end of old[j]) satisfies \(q + \Delta = p\) with the same state after it, then the bytes from \(p\) in \(w'\) equal the bytes from \(q\) in \(w\) (both are after the edit), and the states agree; by determinism the rest of the full relex of \(w'\) is the old suffix shifted by \(\Delta\). If no resynchronization happens, the loop relexes to eof and the result is a full relex. \(\square\)
When it breaks: a lexer with hidden state that is not recorded at token boundaries (a global counter, a symbol table used by a lexer hack, Lesson 1.7) violates determinism-from-a-boundary; the lexer state must be part of what is stored and compared. Unbounded lookahead (a raw string waiting for its delimiter) makes the lookahead extent large, so many tokens become "possibly affected".
5. Complexity¶
Variables: \(n = \lvert w \rvert\); \(m\) = number of tokens; \(d\) = number of tokens relexed after an edit; \(h\) = height of the token tree.
| Technique | Time | Space | Notes |
|---|---|---|---|
| Leading trivia count (Pebble) | \(O(n)\), no extra work | 4 bytes per token | text recovered from the buffer |
| Leading/trailing trivia lists (Roslyn, SwiftSyntax) | \(O(n)\) | trivia pieces per token | structured (kind of each piece) |
| Trivia tokens (rustc_lexer, rowan) | \(O(n)\); parser skips them | one token per trivia run | green trees share subtrees |
| Incremental relexing | \(O(d + \log m)\) with a balanced token tree (locate the first affected token in \(O(\log m)\), relex \(d\) tokens) | old token list + states | worst case \(d = \Theta(m)\) |
Proposition 1.10.11 (Incremental relexing can be forced to relex everything)
For a lexer whose strings may span lines, inserting one " near the start of a file of \(m\) tokens can force \(d = \Theta(m)\) tokens to be relexed. For Pebble, whose strings end at a line break, a single-byte edit relexes at most the tokens of the edited line plus one, provided that in both the old and the new text no block comment and no interpolation is open at the end of the edited line. Without that proviso Pebble also has a \(\Theta(m)\) case: inserting * after a / opens a block comment that can run to the end of the file.
Proof
Multi-line strings: after the inserted quote, every later quote pairs differently (open/close roles swap), so no old boundary is reached in the same state until the end of the file (the tree-sitter box shows this). Pebble: a string piece cannot extend past a line break (E0108 ends it there), so the only lexer state that can survive a line break is an open block comment (trivia, which may span lines) or a non-empty interpolation stack (code inside \( … ) may span lines). Under the proviso, both the old and the new lexer are outside any comment and have an empty stack at the end of the edited line, so the first token of the next line starts at corresponding positions (\(q + \Delta = p\)) in the same state in both runs: the relex resynchronizes there (Theorem 1.10.10), after the tokens of the edited line and at most the one token that crosses its end. The counterexample: in a / b followed by \(m\) lines of code without */, inserting * after the / turns every later byte into comment trivia, and no old boundary is reached in the same state until the end of the file. \(\square\)
At scale. tree-sitter reparses a typical keystroke in microseconds to a few milliseconds regardless of file size, because only the damaged region is relexed and reparsed [TS-Parser]; the Pebble lexer relexes a 10 000-line file from scratch in about 3 ms (104 MB/s in ch01-regexbench, ~300 KB), which is why a batch compiler does not bother.
6. Variants and refinements¶
Tokens with attached trivia¶
- Structured trivia (Roslyn): preprocessor directives and XML doc comments are trivia with their own syntax trees, so tools can inspect them.
- Trivia pieces vs byte counts: SwiftSyntax stores trivia as arrays of
TriviaPiece(spaces(4),lineComment("// x")) [SWIFTSYNTAX-Trivia]; Pebble stores only a count and relies on the buffer.
Trivia as tokens¶
- Green/red trees (Roslyn and rowan): immutable, position-free green nodes shared across versions; red nodes computed on demand add parent pointers and absolute offsets.
- Token-skipping at the parser boundary (rust-analyzer's
LexedStr→ parser input) vs trivia in the grammar (rarely done: it pollutes every production).
Incremental relexing¶
- Lookahead-based invalidation (Wagner–Graham [WG98], tree-sitter): store each token's lookahead extent and invalidate exactly the tokens whose extent crosses the edit.
- Line-based relexing (many editors' syntax highlighters): store the lexer state at every line start and relex line by line until the state at a line start matches the old one; simple and robust when tokens rarely span lines.
7. In real compilers¶
Tokens with attached trivia¶
- Roslyn
src/Compilers/CSharp/Portable/Parser/Lexer.cs—LexSyntaxLeadingTrivia,LexSyntaxTrailingTrivia,LexSyntaxTrivia(tagVisual-Studio-2022-Version-17.12) [ROSLYN-Lexer]. - SwiftSyntax
Sources/SwiftSyntax/Trivia.swift—Trivia,TriviaPiece(tag600.0.1) [SWIFTSYNTAX-Trivia]. - Pebble
Token::LeadingTrivia(pebble/include/pebble/Lex/Token.h); box in §2.
Trivia as tokens¶
- rustc
compiler/rustc_lexer/src/lib.rs—tokenize,Cursor::advance_tokenreturningWhitespace,LineComment,BlockComment(Rust 1.94.1) [RUSTC-Lexer]; box in §2. - rust-analyzer
crates/parser/src/lexed_str.rs—LexedStr::newmapsrustc_lexer::TokenKind::WhitespacetoWHITESPACEand comments toCOMMENT(tag2026-09-21) [RA-LexedStr].
Find where LLVM does it. Clang's lexer can keep comments: open clang/lib/Lex/Lexer.cpp at llvmorg-23.1.2 and find how SkipLineComment behaves when the lexer is in "keep comments" mode. Question: which member function reports whether comments should be returned as tokens? (Quiz llvm-keep-comments.)
Incremental relexing¶
- tree-sitter
lib/src/parser.c—ts_parser__lex,ts_parser__can_reuse_first_leaf,ts_parser__reuse_node;lib/src/lexer.c—ts_lexer__advance(tagv0.25.3) [TS-Parser]; box in §2.
8. Comparison¶
| Technique | Power / precision | Speed | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| Tokens with attached trivia | lossless; trivia owned by tokens | no overhead in lexing | tools see comments next to their tokens | small (count) to moderate (structured pieces) | Roslyn, SwiftSyntax, Pebble |
| Trivia as tokens | lossless; uniform token stream | slightly more tokens; parser skips them | exact positions of every comment | small in the lexer, a skipping cursor in the parser | rustc_lexer, rust-analyzer, the lab's table lexer |
| Incremental relexing | exact (Theorem 1.10.10) | \(O(d + \log m)\) per edit, \(d\) usually tiny | same tokens as a full relex | high (states, extents, resync) | tree-sitter, IDEs, editors |
Choose attached trivia when your syntax tree API should present "a token and its comments" (refactoring, formatting). Choose trivia tokens when you want the lexer to stay simple and let a tree library attach them. Add incremental relexing when the lexer serves an editor; design string and comment recovery so that damage stays local.
9. Assessment¶
| Technique | Quiz ids | Drill | Flashcard tag | Exercises |
|---|---|---|---|---|
| Tokens with attached trivia | trivia-count, roslyn-trailing |
./course drill lexer-modes (hard also asks structure); justification below |
trivia |
E5 |
| Trivia as tokens | trivia-tokens-count, llvm-keep-comments |
justification below | trivia-tokens |
Lab L7 ★ (the table lexer returns trivia tokens) |
| Incremental relexing | incremental-first-token, incremental-worst-case |
justification below | incremental-relexing |
— |
No drill generates trivia or incremental-relexing problems: the computations are counts and boundary searches on a given input, which the quiz asks directly (trivia-count, incremental-first-token); the corpus test PebbleLexer.CorpusIsLossless is the practice for implementing losslessness.
Pitfall
Losslessness is easy to lose in error paths: a lexer that silently skips an invalid byte, or ends an unterminated comment without accounting for its bytes, produces a stream whose concatenation is shorter than the file. Pebble turns every invalid byte into an unknown token for this reason, and the random-input test checks it.
References¶
See the chapter references.