Selection: reject expressions the grammar can't fully consume (#121) - #123
Merged
Conversation
`Selection::parse` returned `parseOr()` without checking that the input was consumed, so anything the grammar didn't understand at the end of an expression was dropped in silence. `:color red chain A resi 5` coloured all of chain A and reported success — a missing operator produced a plausible-looking wrong figure. Every silent-truncation bug in this layer, #116 included, reached the user through that hole. The parser now reports a leftover token or an unclosed paren through an optional out-param, and the enforcement is central rather than per-command: Application::parseSelection and forEachInScope fail closed, returning an empty selection so a malformed expression stops *before* the mutation instead of being announced after it. `:extract chain A resi 5 as carved` no longer carves an object out of the truncated prefix and then declares failure. CommandRegistry::execute turns a recorded error into a failed command, so no handler — present or future — can forget to check. Errors are scoped to a command's own expressions. Dispatch nests (`:run` and the control-flow commands run their body through the same path), so the slot is taken on the way in and handed back on the way out; a child's bad selection no longer replaces `:run`'s "N commands, 1 failed" summary. Two selection paths don't go through command dispatch and needed their own check: the `:foreach VAR in <sel>` header, which used to iterate the prefix silently at exit 0, and interactive `/` search, which was both silent and left the error to be blamed on the next command. Also fixed, found by the new check: `pepseq H.L` matched every histidine. readWord() stops at the documented `.` / `?` wildcards, so the pattern reached matchPepSeq as bare "H" — the matcher has understood both all along. Verified no false positives: 59 corpus expressions plus every selection in the docs and lib recipes parse clean, and the corpus results are byte-identical to the pre-#116 baseline. Fixes #121
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.
Fixes #121.
The bug
Selection::parsereturnedparseOr()without checking the input wasconsumed, so anything the grammar didn't understand at the end of an expression
was dropped in silence:
The first is the shape that matters. A missing operator produced a
plausible-looking wrong figure that survives visual review. Every
silent-truncation bug in this layer — #116 included — reached the user through
that one missing check.
The fix
The parser reports a leftover token or an unclosed paren through an optional
out-param. Enforcement is central rather than per-command, and — this is the
part I got wrong on the first pass — it fails closed:
Application::parseSelectionandforEachInScopereturn an emptyselection on a parse error, so a malformed expression stops before the
mutation rather than being announced after it. Every consumer already guards
on emptiness.
CommandRegistry::executeturns a recorded error into a failed command, sono handler — present or future — can forget to check.
Why fail-closed matters, using the sharpest case:
Errors are scoped to a command's own expressions. Dispatch nests —
:runandthe control-flow commands execute their body through the same path — so the
slot is taken on the way in and handed back on the way out. Without that, a
child's bad selection replaced
:run's"2 commands, 1 failed"summary,destroying the file:line context issue #80 built.
Two selection paths don't go through command dispatch and needed their own
check: the
:foreach VAR in <sel>header (used to iterate the prefix silentlyat exit 0) and interactive
/search (silent, and it left the error to beblamed on the next command).
A second bug the check found
pepseq H.Lwas matching every histidine.readWord()stops at thedocumented
./?wildcards, so the pattern reachedmatchPepSeqas bare"H"— the matcher has understood both all along. Nowpepseq H.V==pepseq H?V==pepseq HLV.Verification
No false positives, checked two ways: the 59-expression corpus and a sweep of
every selection expression in the docs and
lib/recipes both parse clean, andcorpus results stay byte-identical to the pre-#116 baseline. All nine malformed
cases from the issue are rejected with the offending token named. 307 selection
assertions and 46 script assertions green, including regression tests for
fail-closed
:extract, un-poisoned$sele, nested:runattribution, and the:foreachheader.Deliberately not in this PR
:color red not heliixstill colours everything at exit 0 — an unknownkeyword produces no leftover token, so the end-of-input check cannot see it.
It looks like a one-liner but isn't: that same fall-through is how cross-object
dispatch works (
1crnparsed against1ubqlegitimately yields empty until theright object comes round), so distinguishing a typo from a non-matching object
name needs the multi-object loop's cooperation. Filing separately with that
analysis rather than guessing at it here.
🤖 Generated with Claude Code