lexer: slice tokens from the source binary; drop per-char position maps - #399
Conversation
Three token scanners emitted their token and resumed without accounting
for the bytes they had just consumed, so `column` and `byte_offset` ran
behind the real cursor for the rest of the line:
* a short string did not advance past its closing quote, costing one
byte per string (`local x = "ab" ]` put `]` at column 15, not 16);
* a multi-line comment's body was assumed to start after `--[` plus the
level's `=` signs, forgetting the second `[`;
* a `\u{...}` escape charged 4 columns plus the digit count, but the
digit count already includes the closing brace, over-advancing by one.
The first two run the cursor short and the third runs it long, so
`{:eof, pos}` did not report `byte_size(source)` and any consumer
slicing the source by `byte_offset` picked up shifted text. Line
numbers were never affected, so the luac line-number differential is
unchanged.
Verified against every `.lua` file in the repository: with these three
fixes, each token's line, column, and byte offset agree with an
independent scan of the source, where previously 59 of 65 files
disagreed.
The lexer spent most of its time allocating. Every source character
rebuilt a three-key position map, every token's text was grown one byte
at a time with `acc <> <<c>>`, every identifier was compared against the
22 reserved words in sequence before being converted with
`String.to_atom/1`, and every number was classified by three
`String.contains?/2` scans plus a regex before being parsed.
Position is now three bare integers threaded through the scanners --
line, the offset the line's columns are measured from, and the current
byte offset -- and a map is built only when a token or an error is
emitted. The column base absorbs the continuation bytes of multibyte
codepoints, so columns still count codepoints while byte offsets count
bytes.
Token text is now `binary_part/3` out of the source. String escapes and
long-string end-of-line normalization are the only places a token's text
differs from its source bytes, so those collect iodata chunks around
just the rewritten spans and an escape-free string stays a single slice.
Reserved words are pattern-matched function heads, which the compiler
turns into a binary decision tree with no atom conversion. Numbers carry
their shape out of the scanner and go straight to
`:erlang.binary_to_integer/1` and `:erlang.binary_to_float/1`; the
`>= 2^63` decimal-overflows-to-float rule and hex wrap-around are
unchanged.
Tokenizing (prod, median of 20 batched runs):
input bytes before after speedup
metatables 370 29.10us 5.46us 5.3x
fib 180 13.77us 2.50us 5.5x
tables 256 21.02us 4.15us 5.1x
literals.lua 10,339 509.33us 84.53us 6.0x
assertions.lua 14,361 1189.4us 151.40us 7.9x
Behavior is unchanged: token streams are byte-for-byte identical to the
previous implementation across every `.lua` file in the repository plus
a set of hand-written edge cases covering escapes, mixed line endings,
hex floats, malformed numbers, unterminated tokens, invalid UTF-8, and
shebang handling.
🤖 Automated review — Opus + Sonnet consolidated findingsTwo independent AI reviewers (Opus, Sonnet) reviewed this PR. The three claimed position fixes are real and correct (verified by hand-tracing and by a 200k-input differential fuzz), number/string/comment scanning has no semantic drift, line numbers are identical to 🔴 HIGH — single-line comment start column goes to zero/negative on multibyte input (regression)
The comment's start position is materialized after the body is scanned, from the already-mutated
Confirmed by a 200,000-input randomized differential against this PR's own first commit: 2,400 mismatches, 100% of them this bug, 0 of any other kind. It violates the module's own 🟠 MEDIUM/HIGH — token slices >64 bytes pin the entire source binary (both reviewers, independently measured)
On this repo's OTP, 🟡 LOW
Test-coverage notes
Fixes for the HIGH, the retention issue, and the dead guard will be pushed to this branch shortly. |
Three fixes to the token-slicing hot path: * `scan_single_line_comment/8` built its start position after the body was scanned, from a `line_start` the body's multibyte codepoints had already shifted. Each multibyte codepoint in a comment subtracted one from the reported start column, so `"-- é\n"` reported column 0 and `"-- ééé\n"` reported -2, violating the `pos_integer()` column contract and leaking into AST comment metadata. The start position is now built by the caller before scanning, matching the other three scanners. * Token text sliced with `binary_part/3` is a sub-binary, so any token over 64 bytes kept the entire source binary alive: a 200-byte string literal from a 164KB source measured 164,448 referenced bytes. The single-slice paths (string and long-string bodies, identifiers, both comment kinds) now copy with `:binary.copy/1`. The chunked path already copies via iodata. * Dropped the unreachable `value > 255` branch in the `\ddd` decimal-escape clause; `read_decimal_escape/3` never returns a value above 255. The lexing of `\256` is unchanged. Timing on the largest suite fixture (test/lua53_tests/api.lua, 32KB), interleaved rounds against the branch's pre-fix commit, shows the copies are within run-to-run noise and the hot-path win over main holds. Claude-Session: https://claude.ai/code/session_01BYHGFLoHBJAsUrTzjVp5nC
|
Pushed 81c2988, which addresses the automated review's HIGH findings plus the retention issue. |
Rewrites the hot paths of
lib/lua/lexer.ex. Two commits: the first is abehavioral fix to position tracking, the second is a pure-performance
rewrite on top of it.
Why
The lexer allocated far more than it scanned. Per source character it
rebuilt a three-key position map (
advance_column/2, plus sixteen inline%{line: ..., column: 1, byte_offset: ...}literals), and it grew everytoken's text one byte at a time with
acc <> <<c>>. On top of that, eachidentifier was compared against the 22 reserved words in sequence and then
run through
String.to_atom/1, and each number was classified with threeString.contains?/2scans and a regex before parsing.What changed
Position is threaded as three bare integers — line, the byte offset the
current line's columns are measured from, and the current byte offset — and
a position map is materialized only when a token or an error is emitted
(~140 maps instead of ~800 for a 370-byte input). The column base absorbs
the continuation bytes of multibyte codepoints, so
columnstill countscodepoints while
byte_offsetcounts bytes; the "multibyte character as onecolumn" tests are unchanged.
Token text is
binary_part/3out of the source binary. String escapesand long-string end-of-line normalization are the only cases where a token's
text differs from its source bytes, so those collect iodata chunks around
just the rewritten spans — an escape-free string, an identifier, a number,
and a comment are each a single slice. A bare
\ninside a long string isalready the normal form, so it does not even break the span.
Keywords are pattern-matched function heads, which the compiler turns
into a binary decision tree; a non-keyword identifier now falls through with
no sequential comparison and no atom conversion.
Numbers carry their shape (integer / fraction / exponent) out of the
scanner and go straight to
:erlang.binary_to_integer/1and:erlang.binary_to_float/1. The mantissa is normalized in the scanner,where the shape is known, rather than by regex afterwards. The
>= 2^63 → floatdecimal rule andwrap_int64for hex literals areunchanged, as is the
{:invalid_number, pos}error for1e,1e+, andout-of-range magnitudes such as
1e400.The bug (first commit)
Three scanners emitted a token and resumed without accounting for bytes they
had just consumed:
token on the line was one byte short per preceding string —
local x = "ab" ]reported
]at column 15 instead of 16;--[plus thelevel's
=signs, forgetting the second[;\u{...}escape charged4 + digitscolumns, butdigitsalreadycounts the closing brace, so it over-advanced by one.
The net effect was that
{:eof, pos}did not reportbyte_size(source)andanything slicing the source by
byte_offsetpicked up shifted text. Linenumbers were never affected, so the luac line-number differential
convention is untouched. No existing test pinned the wrong values;
regression tests are added for all three.
Verification
mix formatclean; full suite green including--include slow --include differential --include lua53(2642 passing)..luafilein the repo (68 files: fixtures, the integration corpus, the Lua 5.3
official suite) plus 24 hand-written edge cases — escapes,
\z, mixed\r/\n/\r\n/\n\r, hex floats, malformed numbers, unterminatedtokens, invalid UTF-8, shebangs — with the rewrite and with the previous
lexer carrying only the three position fixes. Every token stream is
byte-for-byte identical (68/68 files, 24/24 cases).
line, column, and byte offset from the source and confirms identifiers and
keywords slice back to their own text and that
eof.byte_offset == byte_size(source). The rewrite passes 65/65 checkable files; the lexer onmainfails 59 of them.Numbers
Prod build, median of 20 batched runs, on the playground snippets and two
real files. Absolute times move a few percent between runs with machine
load; the ratios were stable across repeated runs.
The metatables snippet's lexer stage goes from ~29us to ~5.5us, against a
target of 7us.
Scope is
lexer.exand its tests only; parser and compiler are untouched.