Skip to content

Selection: reject expressions the grammar can't fully consume (#121) - #123

Merged
vv137 merged 1 commit into
mainfrom
fix/121-selection-trailing-tokens
Aug 2, 2026
Merged

Selection: reject expressions the grammar can't fully consume (#121)#123
vv137 merged 1 commit into
mainfrom
fix/121-selection-trailing-tokens

Conversation

@vv137

@vv137 vv137 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Fixes #121.

The bug

Selection::parse returned parseOr() without checking 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, reported success, exit 0
:count (chain A               " unbalanced paren accepted
:count chain A OR chain B     " OR isn't an operator; the tail vanished

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::parseSelection and forEachInScope return an empty
    selection 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::execute turns a recorded error into a failed command, so
    no handler — present or future — can forget to check.

Why fail-closed matters, using the sharpest case:

# before this correction: reported the error AND still carved the object
$ :extract chain A resi 5 as carved
Bad selection: unexpected 'resi' in selection    # ...but `carved` now exists,
                                                 # and the source lost 6 atoms

Errors are scoped to a command's own expressions. Dispatch nests — :run and
the 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 silently
at exit 0) and interactive / search (silent, and it left the error to be
blamed on the next command).

A second bug the check found

pepseq H.L was matching every histidine. readWord() stops at the
documented . / ? wildcards, so the pattern reached matchPepSeq as bare
"H" — the matcher has understood both all along. Now pepseq 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, and
corpus 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 :run attribution, and the
:foreach header.

Deliberately not in this PR

:color red not heliix still colours everything at exit 0 — an unknown
keyword 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 (1crn parsed against 1ubq legitimately yields empty until the
right 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

`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
@vv137
vv137 merged commit 3279f3b into main Aug 2, 2026
2 checks passed
@vv137
vv137 deleted the fix/121-selection-trailing-tokens branch August 2, 2026 13:09
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.

Selection::parse silently ignores unconsumed trailing tokens

1 participant