refactor(lexer): rewrite the pipeline around one composed scanner (GH #95)#172
Merged
Conversation
…95) Two cross-model reviews (gemini-pro + fable batches) converged on the same verdict: the four-stage pipeline (two string-rewriting preprocessors → logos → marker re-threading → three fusion passes) communicated through three unchecked channels — in-band marker strings, span adjacency, and pass ordering — and every confirmed corruption bug lived in a blind spot between two stages. Decision (Amy, 2026-07-04): full rewrite, single PR, the ~4,100-test suite as the spec. The logos vocabulary and Token enum are untouched — they encode ~60 tested idiom decisions and none of the bugs were in them. The census (this session) verified all six #95 bugs against current code and found two more: `${X:-$((1+2))}` leaked raw marker text into the AST, and the `#` of `$#` opened comment state in the preprocessor, hiding everything after it from arithmetic extraction. It also settled the two seam questions #95 left open: multiline list/record literals are legal (the parser consumes interior newlines), so context must NOT reset at newline inside an open literal; and nested `$((..))` inside `$( )` was never handed to a subcommand — substitution bodies are lexed inline, so the old skip left inner arithmetic raw and unparseable. New shape: - scan(): one source-order pass with explicit quote/escape/comment state extracts heredocs AND arithmetic together, emitting a COMPLETE replacement table (the old pipeline recorded none for heredocs — every span after a heredoc drifted; body_start_offset is now exact). - Marker resolution is positional (replacement-table ranges, never identifier-name fishing). A word glued onto a marker splits into Arithmetic + re-lexed fragments, so `echo $((1+2))abc` becomes the parser's loud no-token-pasting error instead of leaked marker garbage (bash-style `3abc` interpolation deliberately rejected as scope creep). - compute_value_context() is now an explicit frame stack (Test / List / Record / Subst / Paren) plus a statement-head DFA that follows the real assignment grammar: `x = [a b]` (spaced), `local x = [ab]`, subscripted lvalues, and env-prefix chains open value position; an argv `=` (`grep -E = [a-z]*`) no longer suppresses glob fusion. `$( )` bodies get a fresh scope — `[[ -n $(x=[a]) ]]` no longer leaks test depth. - Fusion passes slice fused text VERBATIM from the source: `a:007` stays `a:007`, `007*` globs as `007*`. - tokenize_with_comments() is now a keep_comments flag on the one pipeline; the divergent second dialect is gone. Behavior changes (all corrupt→loud or broken→works, per the BREAKING- restraint policy): `echo "a << b"` and `# see <<EOF` now lex; apostrophes in heredoc bodies no longer poison later arithmetic; `$((` in literal heredoc bodies is prose; arithmetic inside command substitution now works (bash parity); two heredocs on one line both collect (the parser then rejects ambiguous stdin with its own message); `${X:-$((..))}` is a new loud ArithmeticInVarRef error; `cat <<'EOF'x`-style delimiter words now take bash whole-word quote removal. Gates: cargo test --all (4605 passed, includes 25 new characterization tests in lexer_pipeline_tests.rs), clippy --all --all-targets clean, insta clean, no-default-features check, wasm32-wasip1 build. Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
…r caveat is dead (GH #95); document the two loud errors that replaced silent corruption Co-Authored-By: Claude Fable 5 <[email protected]>
The deepseek consult's HIGH finding (line-continuation before a heredoc body corrupting collection) did not reproduce — the continuation joins the introducer line and the body starts after the first unescaped newline, which is bash semantics; verified end-to-end (cat <<EOF \ | tr a-z A-Z uppercases the body) and pinned with a new characterization test. The real cleanups it found are applied: LBrace/RBrace removed from is_statement_boundary (dead — dedicated arms run first), the replacement-table ordering invariant map_position depends on is now a debug_assert, the in-string marker swap uses replacen(_, _, 1) to match the uniqueness contract, and scan_heredoc_introducer's word_end no longer indexes chars[n-1] raw. Gemini-pro's batch leg came back truncated mid-reasoning; its one visible finding (spaced lvalue vs glob before =) is the documented pre-existing trade-off, unchanged. Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
tobert
added a commit
that referenced
this pull request
Jul 16, 2026
…race (#174) Closes #173. ## The bug Bare `${X:-${Y}}` was a parse error: the `VarRef` regex `\$\{[^}]+\}` stops at the **first** `}`, so it lexed as `VarRef("${X:-${Y}")` + `RBrace`. The quoted form always worked (the in-string interpolation parser hand-rolls brace matching), as did `${X:-$Y}`. Found during the #95 census; pre-existing, unchanged by #172. ## The fix logos regexes can't count nesting, but a callback can extend a match: the pattern is now just `\$\{` and `lex_varref` walks `lex.remainder()` counting brace depth, extending the token to the balanced `}` via `lex.bump()`. **The parser needed zero changes** — `find_default_separator` was already `${}`-nesting-aware and default words already route through `parse_interpolated_string`, which is exactly why the quoted form worked all along. Contracts preserved / clarified: - `${#X}` / `${#u[tags]}` still lex as `VarLength` — its full regex out-matches the two-character opener in logos rule selection. - Depth counting stays quote-blind, matching both the old regex and the scanner's `${...}` region tracking (they use the identical algorithm on identical bytes, so they can't disagree on where a region ends). - `${}` stays an error (old regex required one inner char); unbalanced `${X:-${Y}` is now a proper `unterminated variable reference` instead of a generic unexpected-character. - `${a}b}` closes at the first *balanced* `}` — pinned by a test. ``` $ kaish -c 'echo ${X:-${Y:-fallback}}' # fallback $ kaish -c 'Y=mid; echo ${X:-${Y:-fallback}}' # mid $ kaish -c 'X=top; echo ${X:-${Y:-fallback}}' # top ``` ## Testing & review - Full gates: `cargo test --all` (4,610 passed), clippy `--all-targets` 0 warnings, insta clean, no-default-features check. - New tests: token-shape (single/double nesting, VarLength tie), error cases (unterminated, extra open brace, empty), early-close contract, and kernel-routed evaluation tests; nested example added to LANGUAGE.md's `:-` section (verified in the REPL). - kaibo deepseek consult on the worktree: change judged sound on all five review axes (rule selection, bump math, scanner consistency, marker interaction, coverage); its one suggested pin (`${a}b}`) is included. CHANGELOG bullet under Unreleased/Fixed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #95.
What this is
The full lexer-pipeline rewrite locked in #95 (2026-07-04, after two cross-model
batch reviews converged on it): the four-stage pipeline — two string-rewriting
preprocessors → logos → marker re-threading → three fusion passes, communicating
through in-band marker strings, span adjacency, and pass ordering — is replaced by:
scan()— one composed source-order pass with explicit quote/escape/commentstate extracts heredocs AND arithmetic together, emitting a rewritten buffer plus
a complete replacement table in both coordinate systems (the old pipeline
recorded no replacements for heredocs; every span after one drifted).
Tokenenum are untouched (they encode ~60shipped idiom decisions; none of the bugs lived there).
identifier-name fishing. A word glued onto a marker splits into
Arithmetic+re-lexed fragments so the parser's no-pasting guard rejects it loudly.
HereDocincluded, is an exactoriginal-source byte range;
body_start_offsetis exact (was documented drift).(
a:007staysa:007;007*globs as007*), suppression decided by a newcompute_value_context: an explicit frame stack (Test/List/Record/Subst/Paren)plus a statement-head DFA modeling the real assignment grammar.
Bugs fixed (all corrupt→loud or broken→works)
$((ina
<<'EOF'body is prose (was a false UnterminatedArithmetic).echo "a << b"and# see <<EOFno longer misfire heredoc collection.echo $((1+2))abc— loud no-pasting error with a quote hint (was raw__KAISH_ARITH_…__abcleaked to the user);echo "$((1+2))abc"prints3abc.a:007,007*).=no longer suppresses glob fusion (grep -E = [a-z]*); real assignments(glued/spaced/
local/subscripted/env-prefix chains) still put their RHS at valueposition — census found spaced
x = [a b]is a legal assignment, so the fix is astatement-shape DFA, not span adjacency as Lexer overhaul: rewrite the pipeline around the logos vocabulary (post-0.11.0) #95 sketched.
$( )bodies get fresh context:[[ -n $(x=[a]) ]]no longer leaks test depth.echo $(echo $((1+2)))now prints3(substitutionbodies are lexed inline — the old preprocessor skip left inner arithmetic raw and
unparseable);
${X:-$((1+2))}is a loud newArithmeticInVarReferror (was asilent marker leak into the AST);
$#no longer opens comment state(
echo $# $((1+2))works); two heredocs on one line both collect (the parserthen rejects ambiguous stdin with its own message).
Deviation from the locked plan
The three fusion passes stayed three passes sharing the one new context walker,
rather than collapsing into a single pass: their alphabets differ deliberately
(
Floatcolon-fuses but doesn't glob-fuse —1.5:x*fuses,1.5*stays split),and a union-alphabet pass would change tokenization in corners the suite pins.
Testing & review
cargo test --all: 4,606 passed — the entire pre-existing suite passed thenew engine after exactly one fix (bare-CR heredoc terminators). 25 new
characterization tests in
lexer_pipeline_tests.rspin all eight corruptionclasses dead, plus heredoc-introducer line continuation (verified bash-parity
end-to-end:
cat <<EOF \+| tr a-z A-Zuppercases the body).--all --all-targets0 warnings;cargo insta test --checkclean;no-default-features check; wasm32-wasip1 build.
continuation semantics are bash's; its four cleanups landed) + gemini-pro batch
(truncated output; visible finding was the documented spaced-lvalue trade-off).
Limitation replaced by the two loud errors; devlog entry.
🤖 Generated with Claude Code