Skip to content

refactor(lexer): rewrite the pipeline around one composed scanner (GH #95)#172

Merged
tobert merged 5 commits into
mainfrom
refactor/lexer-overhaul-95
Jul 16, 2026
Merged

refactor(lexer): rewrite the pipeline around one composed scanner (GH #95)#172
tobert merged 5 commits into
mainfrom
refactor/lexer-overhaul-95

Conversation

@tobert

@tobert tobert commented Jul 16, 2026

Copy link
Copy Markdown
Owner

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:

  1. scan() — one composed source-order pass with explicit quote/escape/comment
    state 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).
  2. logos — the regex vocabulary and Token enum are untouched (they encode ~60
    shipped idiom decisions; none of the bugs lived there).
  3. Positional marker resolution — replacement-table ranges, never
    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.
  4. Exact span mapping — every token span, HereDoc included, is an exact
    original-source byte range; body_start_offset is exact (was documented drift).
  5. Fusion with verbatim slices — fused text is sliced from the source
    (a:007 stays a:007; 007* globs as 007*), suppression decided by a new
    compute_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)

  • Apostrophe in a heredoc body no longer poisons later arithmetic; literal $(( in
    a <<'EOF' body is prose (was a false UnterminatedArithmetic).
  • echo "a << b" and # see <<EOF no longer misfire heredoc collection.
  • echo $((1+2))abc — loud no-pasting error with a quote hint (was raw
    __KAISH_ARITH_…__abc leaked to the user); echo "$((1+2))abc" prints 3abc.
  • Leading zeros survive fusion (a:007, 007*).
  • Argv = no longer suppresses glob fusion (grep -E = [a-z]*); real assignments
    (glued/spaced/local/subscripted/env-prefix chains) still put their RHS at value
    position — census found spaced x = [a b] is a legal assignment, so the fix is a
    statement-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.
  • Census bonus finds: echo $(echo $((1+2))) now prints 3 (substitution
    bodies are lexed inline — the old preprocessor skip left inner arithmetic raw and
    unparseable); ${X:-$((1+2))} is a loud new ArithmeticInVarRef error (was a
    silent marker leak into the AST); $# no longer opens comment state
    (echo $# $((1+2)) works); two heredocs on one line both collect (the parser
    then 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
(Float colon-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 the
    new engine after exactly one fix (bare-CR heredoc terminators). 25 new
    characterization tests in lexer_pipeline_tests.rs pin all eight corruption
    classes dead, plus heredoc-introducer line continuation (verified bash-parity
    end-to-end: cat <<EOF \ + | tr a-z A-Z uppercases the body).
  • clippy --all --all-targets 0 warnings; cargo insta test --check clean;
    no-default-features check; wasm32-wasip1 build.
  • kaibo review pair on the worktree: deepseek consult (its HIGH didn't reproduce —
    continuation semantics are bash's; its four cleanups landed) + gemini-pro batch
    (truncated output; visible finding was the documented spaced-lvalue trade-off).
  • Docs: CHANGELOG bullets; LANGUAGE.md's "context-unaware preprocessing" Known
    Limitation replaced by the two loud errors; devlog entry.

🤖 Generated with Claude Code

tobert and others added 5 commits July 16, 2026 11:39
…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]>
…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]>
@tobert tobert merged commit 9dc4180 into main Jul 16, 2026
3 checks passed
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Lexer overhaul: rewrite the pipeline around the logos vocabulary (post-0.11.0)

1 participant