Lexer / Scanner — Senior¶
This file is about the production scanner inside go build: cmd/compile/internal/syntax. We cover its mental model, the buffered source rune reader, why it is fast, how it feeds the parser, error recovery, and position encoding. The std-lib go/scanner is the same idea with a simpler buffer model; the compiler one is tuned harder.
1. The gc scanner mental model¶
The compiler's lexical layer is three small, self-contained files:
| File | Responsibility |
|---|---|
source.go | buffered rune reader: bytes → runes, position tracking, segments |
scanner.go | token recognizer: drives source, classifies tokens, ASI, errors |
tokens.go | the token, Operator, LitKind enums and precedence table |
The comment at the top of scanner.go notes these (plus the generated token_string.go) are deliberately self-contained — they compile on their own and could be a separate package. That isolation keeps the hottest code in the compiler decoupled from everything else.
The scanner struct embeds source and adds token state:
type scanner struct {
source // embedded buffered reader
mode uint // which comments/directives to report
nlsemi bool // pending automatic semicolon
line, col uint // start position of current token
blank bool // line is blank up to col (for directive checks)
tok token // current token kind
lit string // literal text (name/literal/semi)
bad bool // literal is malformed
kind LitKind // IntLit/FloatLit/ImagLit/RuneLit/StringLit
op Operator // for operator tokens
prec int // operator precedence
}
The parser holds one scanner and calls next() to advance. There is no token slice; tokens are produced one at a time, on demand, with no look-behind buffer. The parser does at most one token of lookahead by keeping the current token in these fields.
2. The source buffered rune reader¶
source is the cleverest part. It reads from an io.Reader into a byte buffer and hands out runes one at a time, while keeping the buffer reusable and tracking line/column. Its layout (from the file's own diagram):
+------ content in use -------+
v v
buf [...read...|...segment...|ch|...unread...|s|...free...]
^ ^ ^ ^
| | | |
b r-chw r e
Three indices:
b(begin): start of the active segment — the bytes of the literal or identifier currently being scanned, or-1when no segment is active.r(read): one past the most recently decoded characterch, which starts atr-chw(chwis its byte width).e(end): one past the last byte read from the underlying reader.
The buffer is always terminated at buf[e] with a sentinel byte equal to utf8.RuneSelf (0x80). That sentinel is the whole trick behind fast ASCII scanning (next section).
nextch — the per-character engine¶
func (s *source) nextch() {
redo:
s.col += uint(s.chw)
if s.ch == '\n' {
s.line++
s.col = 0
}
// fast common case: at least one ASCII character
if s.ch = rune(s.buf[s.r]); s.ch < sentinel {
s.r++
s.chw = 1
if s.ch == 0 {
s.error("invalid NUL character")
goto redo
}
return
}
// slow path: multibyte rune, refill, EOF, BOM, invalid UTF-8 ...
for s.e-s.r < utf8.UTFMax && !utf8.FullRune(s.buf[s.r:s.e]) && s.ioerr == nil {
s.fill()
}
// ... DecodeRune, handle RuneError, handle BOM ...
}
The single if s.ch < sentinel test handles the overwhelmingly common case — ASCII source — with one comparison, one increment, and a return. Because the buffer is sentinel-terminated at buf[e], that same test also detects "we ran out of buffered bytes" (the sentinel is >= RuneSelf), so there is no separate bounds check in the hot path. Multibyte UTF-8, refilling, EOF, the BOM check, and invalid-encoding handling all live on the slow path after the fast if.
Segments: zero-copy literal text¶
When the scanner starts a token it may call start() to mark b; when done it calls segment() to get the bytes:
func (s *source) start() { s.b = s.r - s.chw }
func (s *source) stop() { s.b = -1 }
func (s *source) segment() []byte { return s.buf[s.b : s.r-s.chw] }
segment() returns a slice into the buffer — no copy. The scanner only copies to a string when it must keep the text (identifier or literal). For operators, delimiters, and skipped comments it never allocates at all.
rewind — one ugly corner¶
Go's grammar has exactly one place needing more than one rune of lookahead in the source layer: distinguishing ... from .. from .. The scanner handles it with rewind, which resets r and col to the segment start. The file comment is blunt: "Currently, rewind is only needed for handling the source sequence '..'". It must not cross a newline (it adjusts col, not line).
fill and buffer growth¶
fill preserves the active content (b..e or r..e), shifts it to the front or grows the buffer via nextSize, then reads more. nextSize doubles from a 4 KB minimum up to 1 MB, then grows linearly:
func nextSize(size int) int {
const min = 4 << 10 // 4K minimum
const max = 1 << 20 // 1M cap on doubling
if size < min { return min }
if size <= max { return size << 1 }
return size + max
}
The reader is retried up to 10 times per fill before giving up with io.ErrNoProgress, defending against pathological io.Readers that return (0, nil).
3. Performance of scanning¶
Scanning runs over every byte of every file you compile, so it is engineered to be allocation-light and branch-cheap:
- One comparison for ASCII. The sentinel trick collapses bounds check and ASCII test into a single
if. - Zero-copy segments. Operators and discarded comments allocate nothing; only kept literals/identifiers become strings.
- Perfect-hash keywords.
keywordMap[hash(lit)]with one confirming string compare — no map, no allocation, no linear scan. - Buffer reuse across files.
source.initkeeps an existings.bufif present, so a scanner reused for many files does not reallocate. - No token objects. Tokens are flat fields on the struct, not heap nodes.
In a normal build the scanner is a small fraction of total time (type checking and SSA dominate), precisely because it was kept this lean.
4. How the scanner feeds the parser¶
The parser (cmd/compile/internal/syntax/parser.go) embeds the scanner and drives it directly. There is no intermediate token stream and no channel (an early, since-removed Go prototype used a goroutine + channel; it was replaced because direct calls are far faster). The pattern:
// parser advances by calling the embedded scanner's next()
func (p *parser) next() { p.scanner.next() }
// typical use: check the current token, then advance
if p.tok == _Lbrace {
p.next()
// ... parse block ...
}
ASI is invisible to the parser: by the time the parser sees tokens, a \n after an identifier has already become a _Semi. The parser's grammar simply expects semicolons between statements; it never reasons about newlines.
5. Error recovery¶
The scanner never panics on bad input and never stops at the first error. It reports through an installed handler and keeps producing tokens:
func (s *source) error(msg string) {
line, col := s.pos()
s.errh(line, col, msg) // errh is supplied by the caller
}
Recovery strategy by case:
- Malformed literal (
bad underscore,unterminated string): the token is still emitted as_Literalwithbad = true; the parser treats it as a literal and continues, so one typo does not cascade. - Invalid UTF-8 / NUL: reported, then
goto redore-reads — the bad byte is consumed and scanning resumes. - BOM mid-file: reported but skipped; a leading BOM is silently allowed.
- Unterminated block comment:
comment not terminated, then EOF.
The errh callback (a func(line, col uint, msg string)) is set by scanner.init. The compiler installs one that funnels into its error list, deduplicates, and applies the "too many errors" cutoff.
6. Position encoding¶
The compiler tracks positions with PosBase and Pos (in pos.go / positions.go), not the std-lib token.Pos. Two ideas:
-
sourcekeeps 0-basedline, colas it reads, incrementingcolbychwper character and resettingcoland bumpinglineon\n. The public position is 1-based:pos()returnslinebase+line, colbase+col(both bases are 1). -
PosBaseanchors a position to a file (or to a//line-redirected file). A//line file.go:10directive installs a newPosBaseso that subsequent positions report the directive's file and line — essential for cgo and code generators that want errors to point at the original source.
// conceptually: Pos = (PosBase, line, col)
// //line foo.go:100 → new PosBase{filename:"foo.go", line:100}
// the next physical line then reports as foo.go:100
This is why a stack trace from cgo-generated code can point back into your .go file: the scanner captured the //line directive and rebased subsequent positions.
The std-lib go/token does the analogous thing with File.AddLineColumnInfo / File.AddLineInfo, populated when go/scanner sees a //line directive, so fset.Position reports the rebased location too.
7. Comments and directive capture¶
By default the scanner discards comments entirely — it never even allocates their text. Two mode bits change that: comments (report all comments) and directives (report only //line, /*line, and //go: comments). The parser runs in directives mode because the compiler must act on pragmas and line directives but does not care about ordinary comments.
The cheapness of directive detection is deliberate. lineComment bails out before capturing text unless the very next character is g or l:
// from scanner.go lineComment(), in directives mode
if s.mode&directives == 0 || (s.ch != 'g' && s.ch != 'l') {
s.stop() // not a directive: drop the segment
s.skipLine() // consume to end of line without keeping bytes
return
}
Only after matching the exact go: or line prefix does it keep the segment. So the common case — a plain // comment — costs a fast scan to end-of-line and zero allocation. This is why directives must be the glued //go:///line form: the scanner's first-character gate would reject anything else before even looking at the rest.
8. Senior takeaways¶
- The gc lexer is three tight files;
sourceis a sentinel-terminated buffered rune reader optimized so ASCII costs one comparison. - Literal text is handed out as zero-copy slices (
segment()); strings are materialized only when retained. - The scanner produces tokens on demand with one-token lookahead; the parser calls
next()directly, no channel, no token slice. - ASI is resolved entirely in the scanner via
nlsemi, so the parser's grammar is newline-agnostic. - Errors are reported through
errhand recovered from; bad literals are still emitted so parsing continues. - Positions are
(PosBase, line, col);//linedirectives rebase them, which is how generated code reports source-accurate errors.
Further reading¶
source.go(buffered reader): https://go.dev/src/cmd/compile/internal/syntax/source.goscanner.go: https://go.dev/src/cmd/compile/internal/syntax/scanner.gotokens.go: https://go.dev/src/cmd/compile/internal/syntax/tokens.gopos.go/positions.go: https://go.dev/src/cmd/compile/internal/syntax/positions.go- Go spec, "Semicolons": https://go.dev/ref/spec#Semicolons
go/tokenposition model: https://pkg.go.dev/go/token#FileSet