docs/rationale/parse-sh-single-tokenizer.md
The first parse-sh implementation (the 4.2.0-rc branch) split lexical
knowledge across several independent scanners: the lexer's word reader
owned quotes, escapes, and comments; the interpreter re-scanned the raw
line with its own quote rules to detect here-documents and again to read
the here-doc delimiter; and a separate pre-pass joined backslash-newline
continuations, duplicating the lexer's own continuation stripping. That
structure produced a whole bug class from those scanners drifting apart:
the here-doc detector did not understand comments, so a << inside a
trailing # comment silently swallowed all subsequent lines; its
delimiter reader used a different word-break set
than the lexer, so a here-doc inside a subshell swallowed the rest of
the input; and a redirect-target reader dropped the unterminated-quote
signal the word reader had already computed, fabricating executions from
quoted text. Point fixes close each hole, but the shape guarantees more:
any two scanners over the same text will disagree eventually.
Alternatives on the table for a structural fix:
>|
operator, comment boundaries) and would survive a mechanical rewrite.
Second, shell is hostile to combinator parsing: a here-doc body binds
at the next newline, not at the << operator, forcing stateful
two-phase lexing that fights the combinator model (real shells
hand-write their lexers for this reason). Third, a full AST inverts
the contract: the requirement defines a supported subset with loud
skips and "never guesses"; certifying that property means auditing
what the lexer recognizes, which is easy, not what a general parser
accepts, which is not. It would also add a dependency against the
workspace's minimal-dependency rule for no removed complexity.parse-sh is split into a single streaming tokenizer and a token-stream
parser: the tokenizer is the only code that reads characters (quotes and
escapes, comments, backslash-newline continuations, redirect operators
and their targets, here-documents, and the recursive-make directory
markers) and emits a small token set plus recoverable skip items for
unsupported constructs, while the parser consumes those tokens and owns
all interpreter state (working directory, the marker-driven directory
stack, and the positional VAR=value environment overlay).
Both stages are incremental state machines that hold at most one logical
line, so a skip anywhere on a line can still poison the whole line while
memory stays bounded by the longest line. Three invariants keep the
split sound: the tokenizer keeps scanning a line after a skip (so a later
<< on the same line is still seen) and the parser discards to the next
newline; quote state resets at every newline, matching the line-oriented
contract; and the tokenizer does no ambient I/O, surfacing read failures
in-band as the stream's last item so the caller cannot lose them.
Result forces every consumer to
handle it, which is the loud-skip contract expressed in the type.>| class of bug) is not prevented by this shape, only made cheaper
to add in one place. The skip path remains the pressure valve; the
subset must not grow toward a full shell (see
parse-sh-producer).interception-shell-text-parsing
(the contract whose loud-skip clause drives the design).