Split a caller-supplied TokenType::Eof off before the grammar runs - #450
Conversation
|
Full run, against Two things I looked at while chasing this and am reporting rather than pushing, since I
|
a61e6db to
f92d2f1
Compare
|
Rebased onto
No failed or cancelled checks. Worth noting for the record that |
|
Thanks for the follow-up and the thorough verification. The shared I’d recommend a small follow-up before merging. Additional token-stream probes identified one regression and two remaining gaps in the intended EOF guarantees:
Could you please add these cases to the existing EOF and data-type test files, covering both These look addressable within the current approach; I don’t think they require a new parser API or a wholesale rewrite of |
f92d2f1 to
5d51206
Compare
|
Thanks — all three are fixed, and the first one was mine. Rather than fix them where they What the three had in commonYour probes and my own follow-up sweep say the same thing: making So the terminator is now split off in the constructors — the tokens before it are the Tokens after a terminator are a stream built wrongly rather than input. A constructor cannot Your three points1. 2. A parser consuming EOF before the guard runs. Confirmed: 3.
The 98 corrects my own earlier figure: I had swept four fixture files (994 inputs) and reported Nine of the thirteen were the same error message at a different position, because TestsIn the two files you named, covering
Each case was checked against the previous revision to confirm it actually fails there, rather VerificationAgainst
Also
|
|
Thanks for the update and the expanded regression coverage. I rechecked commit I’d recommend one focused follow-up before merging: EOF-first streams can now panic instead of returning a parse error. I reproduced these cases:
The panic is The cause appears to be the interaction between normalization and error construction: truncating at a leading EOF leaves an empty vector, while Could we please:
For context, all 1,330 locally executed tests passed, with two additional tests ignored, and formatting passed. An additional 44,154 appended-EOF comparisons also passed; the missing coverage is specifically the empty-prefix cases above. The constructor-normalization approach still looks sound. This should be addressable without changing the overall design. |
|
Thanks — reproduced all four rows, and it was mine. Fixed, along with one adjacent panic that The causeExactly as you diagnosed. Normalizing a stream that begins with a terminator truncates to an Three changesError construction is safe for an empty vector. The offending token's span is carried alongside its type, as you suggested, so a placement
One of these was not my regressionWorth separating, because it changes what the fix is worth.
Reproduced through Tests
VerificationAgainst
Also The appended-terminator sweep is unchanged at 0 of 11,311 — these fixes touch error |
`Tokenizer` never emits the variant, but `Parser::new` and friends are public, so a
caller can hand the parser a stream carrying one. It means end of input, and such a
stream must parse identically to one without it.
Recognising it in `is_at_end` answered that in one reader while over a hundred others
went on asking whether the index had reached `self.tokens.len()`. So the terminator
is split off in the constructors instead: the tokens before it are the input, and the
grammar never sees it. Two streams that differ only by a terminator become the same
stream, which makes the invariant structural rather than a property every reader has
to remember -- including `peek`, whose span every parse error is built from.
Over all 38 fixture files, 11,311 distinct inputs parsed with and without an appended
terminator: 98 differences on the base revision, 0 here. `BEGIN` is the clearest of
them -- with a terminator it stopped being a `Transaction` and became an opaque
`Command("BEGIN ")`, a different AST node.
Tokens *after* a terminator are a stream built wrongly rather than input. A
constructor cannot fail, so the offending token's type and span are recorded and
refused by `ensure_complexity_guards`, the one hook all three public entry points
already run, exactly once, before any grammar. The span is kept so the error points
at that token rather than at what is left of the stream after normalizing it.
A stream that *begins* with a terminator normalizes to no tokens at all, and two
things assumed otherwise:
- `parse_error` built its span from `peek`, which asserts a token exists, so every
entry point panicked with `Token list should not be empty` where the base returned
an error. It now takes its span the way `end_of_input_error` beside it always has.
- `parse_statement` and `parse_standalone_data_type` owe a value an empty stream
cannot provide, and dispatched into the grammar regardless. They answer end of
input instead. This one predates the branch -- `Parser::new(Vec::new())` panicked
identically before it -- and normalization only added a second door to it.
`parse` needs no such guard: no statements is the right answer for no tokens, and a
terminator-only stream now parses like `parse_sql("")` rather than erroring.
`is_at_end`, `advance` and `advance_text` keep their checks. They are unreachable now
that no constructor admits an embedded terminator, and kept for the reason the five
original comparisons were kept: this is the safe side to err on.
5d51206 to
d3e5ce4
Compare
Guard the shared primary-expression path when explicit EOF normalization leaves an empty token stream, so public fragment helpers return no expression instead of panicking. Cover all five affected helpers and all three constructors in the existing EOF tests, including repeated calls, malformed EOF placement, and valid-input controls. Validation: make test-rust-verify; focused EOF, data-type API, and error-handling tests; cargo fmt --all -- --check; 44,154 corpus comparisons with no differences or panics.
|
Thanks, merged with a few updates |
Follow-up to #447. A caller-supplied
TokenType::Eofmust not change the parse; this makesthat hold by construction.
The invariant
Tokenizernever emits the variant, butParser::newand friends are public, so a caller canhand the parser a stream carrying one. It means end of input, so a terminated stream must
parse identically to an unterminated one. #447 added
explicit_eof_token_testsassertingexactly that, and pinned the one case where it did not hold.
0.11.0 fixed that case by adding
&& !self.check(TokenType::Eof)to the scan it surfaced in.That scan was not special: there are 109 places in
parser.rsthat compare an indexagainst
self.tokens.len()to ask whether the input has run out, and to every one of them aterminator left in the stream is a token.
The fix
The terminator is split off in the constructors. The tokens before it are the input, and
the grammar never sees it:
Two streams that differ only by a terminator become the same stream, so the invariant is
structural rather than a property each of those 109 readers has to remember. It also covers
peek, whose span every parse error is built from — without that, two streams could producethe same error at different positions.
Tokens after a terminator are a stream built wrongly rather than input. A constructor cannot
fail, so the offending token's type is recorded and refused by
ensure_complexity_guards, theone hook all three public entry points —
parse,parse_statement,parse_standalone_data_type— already run, exactly once, before any grammar.What it fixes
Sweeping all 38 fixture files, 11,311 distinct inputs, parsed with and without an appended
terminator and compared:
is_at_endonlyThat first number corrects something in this PR's own history, so it is worth being explicit
about. My earlier sweeps used four fixture files — 994 inputs — and reported 1, which is
where the
BEGINframing came from. Over all 38 files it is 98, spanningALTER TABLE … MODIFY COLUMN …(eleven variants), theBEGINfamily,CALL a.b.c(x, y),CREATE STAGE …,1 divand more. The defect was considerably more widespread onmainthan either of us hadmeasured; the small corpus undersampled it.
BEGINis still the clearest single case: with a terminator it stopped being aTransactionand became an opaque
Command { this: "BEGIN " }— a different AST node, so anything matchingon the AST saw something else.
The middle row is the first revision of this PR, and it is why the mechanism moved. Answering
the question in one reader left the others asking a different one:
parse_standalone_data_type's!is_at_end()trailing checkINT,Eof,SELECT,2Ok(Int), dropping the trailing statementadvance_texttesting the stream lengthSET,Eof,x,=,1SetStatementwith an empty identifieris_last_expression_tokentesting the next indexSELECT 1 is+ 10 moreSELECT * FROM t LIMIT 10%went from parsing to failingpeekreturning the zero-span terminatorAll of them are fixed by the terminator not being there.
Empty and terminator-first streams
Normalizing a stream that begins with a terminator leaves no tokens at all, and the first
revision of this change did not allow for that:
parse_errorbuilt its span frompeek, whichasserts at least one token, so all three entry points panicked with
Token list should not be emptywhere the base returned an error. Found by @tobilg's probes. Three things close it:parse_errortakes its span the wayend_of_input_errorbeside it always has —tokens.get(current), falling back tolast(), thenunwrap_or_default().that token. Better than the first revision managed even on non-empty streams, which built the
span from
peekon the truncated remainder:Eof → SELECT → 1reportedline 0, column 0and now reports
line 1, column 7.parse_statementandparse_standalone_data_typeanswer end of input on an empty streaminstead of dispatching on a token that is not there.
parseneeds no guard: no statements isthe right answer for no tokens.
…tokens… Eof …more…Err("Unexpected token: Eof")Err("Unexpected token after end of input: …"), at the offending tokenEofaloneparseErr("Unexpected token: Eof")Ok([])— likeparse_sql(""),parse_sql(" ")and an empty token vectorEofaloneparse_statement,parse_standalone_data_typeErrErr("Unexpected end of input")Vec::new()parse_statement,parse_standalone_data_typeErr("Unexpected end of input")That last row is not a regression from this branch: an empty token vector has always been
constructible through
Parser::new, and those two entry points panicked on it before any ofthis. Normalization added a second door to the same assumption by making a terminator-only
stream empty, so the guard closes both.
The comparisons stay
All eight, including the one 0.11.0 added, and the checks in
is_at_end,advanceandadvance_text. They are unreachable now that no constructor admits an embedded terminator,and kept for the reason @tobilg gave for keeping the original five: removing a comparison
against this variant is what the first revision of #447 got wrong.
Tests
parser.rs,explicit_eof_token_tests:test_an_explicit_eof_token_does_not_change_the_parse(36 statements),test_tokens_after_an_eof_token_are_refused_by_every_entry_point(all three entry pointsplus the
SETcase),test_a_trailing_keyword_is_read_the_same_with_and_without_a_terminator(all 13 from the sweep),
test_a_stream_of_only_an_eof_token_parses_as_empty,test_a_raw_unset_clause_stops_at_an_explicit_eof,test_unset_property_with_an_explicit_eof_is_not_a_raw_clause.test_a_stream_beginning_with_an_eof_token_errors_rather_than_panickingcovers leading,duplicate and terminator-only streams across all three entry points and all three public
constructors —
new,with_config,with_sourceshare the normalization — including anassertion that the placement error carries the offending token's position.
tests/data_type_api.rs:parse_standalone_data_type_treats_an_eof_token_as_end_of_input— the token-stream entrypoint, which
parse_data_typecannot reach through a string — andparse_standalone_data_type_errors_on_an_empty_or_eof_first_stream. 11 → 13 tests.Every case was checked against the revision it fails on, rather than assumed.
Verification
Against
47342ba.cargo fmt --all -- --checkclean.make test-rust-verifyrerun after the adjustment — exit 0, every step green: lib1236 passed / 0 failed / 2 ignored, generic identity 977/977, dialect identity 4086/4086,
transpilation 6058/6058 (4 known failures), transpile generic 154/154, parser 32/32,
pretty-print 23/23, custom dialect 276/276 + 347/347, ClickHouse parser 9417 parsed /
0 failed, ClickHouse coverage 100% on every group, FFI 75 passed.
Also
cargo test --test error_handling62 passed andcargo test --test data_type_api13passed.
🤖 Generated with Claude Code