Skip to content

parser fixes, 100% pass on default syntax tests - #705

Open
titoBouzout wants to merge 21 commits into
trishume:masterfrom
titoBouzout:syntect-parser-fixes
Open

parser fixes, 100% pass on default syntax tests#705
titoBouzout wants to merge 21 commits into
trishume:masterfrom
titoBouzout:syntect-parser-fixes

Conversation

@titoBouzout

Copy link
Copy Markdown

parser fixes, 100% pass on default syntax tests

Ran into an issue with a syntax file, so I put Claude to investigate, and it got in a rabbit hole/loop of fixes for a couple of days/weeks that involved checking issues/prs/ST syntax documentation and cross checking against ST v4206 via a live plugin probing apis.

Long story short it got to pass 100% of default packages syntax_tests. I've instructed to avoid any refactor/performance improvements and concentrate on correctness. It reused/fixed/discarded existing prs/issues and made its own.

Disclaimer: I haven't written a line of rust on my life. So Im uncertain on the quality. Ive vendored syntect with this patch, so probably I won't follow up, cant really evaluate this myself. Didnt split in different prs for those reasons. The code/information provided may be useful.

What follows is all ai generated.


Thirty-five parser fixes, three harness rules, and this corpus at zero

A fork of syntect that has been in production use for some months. Every change
in it exists because a file came out coloured wrong, and where the reference did
not settle what the right answer was, Sublime Text was asked directly rather than
argued with — it documents every feature on its own and never the cross-product,
which is where most of these bugs live.

make syntest reports no failing file, on both engines, against 414 failing
columns and eight panics on master — as master's own harness reports it, and
part of that 414 is the harness rather than the parser, which this branch fixes
too. It also settles 38 of the 63 open pull requests — applied, fixed
differently, superseded or contradicted — and the tables under "Your tracker"
are the actionable part of this.

Prior art: most of the diagnosis in the parser half is @stefanobaghino's, in
the #631 chain. Five of these changes are that patch carried over, and several
more are the same root cause found by following a PR body to the file it names.
Where this branch differs, it is because ST was asked something the PR did not
ask — and those answers are in the tables.


1. The numbers

testdata/Packages was pinned at 1ba99a47 (v4050-1502, April 2026), so the
tables in the repository measured against definitions four hundred commits old.
This branch bumps the submodule to 69457400 (v4206-2) and regenerates them.
Everything below is on that corpus.

master this branch
failing columns 414 0
files with failures 21 0
panics 8 0
assertion columns passing 567,889
files tested / skipped 206 / 34

The largest files, for scale: Java 44,362 assertion columns, Bash 40,789,
MySQL 28,549, Zsh 28,463, Markdown 25,705, PHP 21,338, Go 19,153.

Master's eight panics are recorded in its table as 1 each — seven in the
harness (easy.rs:289, ops from one line walked over another's text) and one in
the parser. The 1 is the crash, not the file: at a point in this branch where
the harness was fixed but most of the parser work was not, those files reported
figures in the tens of thousands (Java 33,243).

How the last failures went, for anyone reproducing this:

step remaining
on the old v4050 pin, with #654 and #678 85
bumping the corpus to v4206 47
#674 — an assertion lives on a line that is nothing but the assertion 0

The 38 the bump took were C#'s two files: its definition has moved 4,549 lines
since v4050 and syntax_test_Generics.cs no longer exists. The 47 #674 took were
one shape — a source line containing the testtoken read as an assertion, which
both invents a selector (pattern}, out of ${#^pattern}) and stops that line
counting as the one the assertions below it are measured against. Haskell's six
were the same thing: --> in a file whose testtoken is --.


2. What is in it

area changes
branch / fail, exhaustion, loop guards, cross-line replay 10
extends, prototypes, captures: (one of them a panic on a user-written file) 7
pop: N combined with set / push / branch / embed 4
clear_scopes ordering 4
embed: / escape: and embed_scope 3
version: 1 compatibility gates 3
scope names and scope selectors 2
.tmPreferences metadata 2
fixes in the library 35
separable additions: literal prefilter, dump_uncompressed_to_writer 2
the syntax-test harness, examples/syntest.rs#654, #674, #678 3

The prefilter (regex.rs) takes the literals every match of a pattern must begin
with and skips the engine when they are absent from the line. It is a necessary
condition only
, and on a debug build every rejection is put to the engine it
skipped, so a disagreement between regex-syntax and Oniguruma fails there
rather than colouring something wrong. Drop it if it is unwanted; nothing else
depends on it.

Four that no test file catches

Found by reading the reference against the parser, each confirmed with ST:

  • Three of Compatibility's six version: 1 behaviours were ungated — capture
    group order, the summed clear_scopes of a multi-context push:, and an
    embed:'s escape. Nothing in the corpus exercises them, so nothing failed; in
    the set this fork builds, 159 of the 334 definitions read from a file declare
    no version: at all — which is version 1 — and were getting version 2's
    answers. The reference states the capture-order rule backwards
    its example says the x loses its scope, ST drops the y — so implementing it
    from the paragraph would have put it in reversed.
  • A refused extends left a syntax with no main, and ParseState::new
    panicked on it. The guard exists already for the missing-parent case; the
    version-mismatch and different-base refusals were not routed through it. Two
    lines in a user-written syntax reach it.
  • ScopeSelectors had no conjunction. (source.c | source.c++) & comment.block split into four selectors, one a bare source.c++, so a
    comment-scoped .tmPreferences answered for a whole language. &, grouping
    and their precedence are implemented, with score_selector behind each rule.
  • KEYS_WE_USE dropped three of the indentation keys
    indentSquareBrackets, preserveIndent and indentOnPaste. A key not in
    that list is discarded while the entries are merged, so a field for one of
    them would have read None everywhere and nothing would have looked wrong;
    indentParens was in the list, on the struct, and never asked for. They are
    the half of the indentation rules that is not a pattern — a pattern cannot
    count brackets — and in testdata/Packages at this pin seven files carry the
    first and twelve the second, with indentOnPaste in none of them (it is nine,
    twelve and one across the whole set this fork builds, third-party definitions
    included). Three names, three Option fields, four accessors, no parser
    code, and the_indentation_flags_are_read for the three. What each one
    means was measured rather than read: the reference gives them a sentence and
    never says what a definition that mentions none of them gets, and the answer
    is that ST's own closed Default package ships a .tmPreferences scoped to
    bare source with indentParens: true — so the default belongs to a file and
    not to this library, which is why these are Option<bool> and not bool.
    assets/default_metadata.packdump is regenerated with them, as make packs
    writes it.

3. Breaking changes

  • ParseLineOutput::replayed becomes Vec<(usize, Vec<(usize, ScopeStackOp)>)>:
    each correction carries the 1-based parse_line count it is for. The
    public-api snapshot is updated.
  • MatchOperation::Push becomes Push { ctx_refs, pop_count } — this is Distinguish pop:N+push:/branch:/embed: from pop:N+set: at MatchOperation IR #687.
  • ScopeSelector gains conjuncts: Vec<ScopeStack>.
  • assets/*.packdump and assets/default.themedump are regenerated, and must
    be
    : both are bincode over types that changed shape, and without make packs
    / make themes the test suite aborts on a nonsense allocation. Worth knowing
    for any future change to a serialised type.
  • Two existing tests change expectations, deliberately, each explained at the
    test: can_parse_backrefs (an embedded SQL (Basic) arrives with one scope of
    its own, not its ancestors' chain) and the v1 half of
    v2_push_multiple_clear_scopes_each_applies (a version 1 multi-context push:
    clears the sum — Compatibility says so and ST agrees).

4. Your tracker

Your patch, carried here

PR note
#646 restore cleared scopes before Pop in pop: N + set: as-is — it applies to master directly, since #645 is already in
#664 fall through to parent on branch_point exhaustion taken, with two adaptations
#669 defer target Clear in a single-context Set as-is
#677 defer LRD blank-line pop to the next-line baseline as-is; it was the whole answer to the last failing assertion against a current Packages
#692 bound zero-width escape fires — the Perl POD hang, issue #650 your diagnosis and your fix, both halves

Same fix, arrived at independently

Found here before the PR was read, or written from its description; either way the
bug and the remedy are the same, so they are yours to close.

PR note
#653 order apply_prototype's external prototype before the target same fix
#667 + #687 pop: N + push: dropped the push; distinguish it at the IR same design, one change; the trigger's scopes came from probing ST
#681 mirror the same-line rewind+skip on cross-line exhaustion same bug — the cross-line arm of #664, and it arrives with it
#691 non-consuming multi-context push loop guard same fix, same shape — widen the armed range to (pre, post]
#654 wrap past-EOL syntest assertions to the next row same rule, re-probed: three probes pin which column the overflow lands on
#674 skip non-pure assertion lines same rule — and it is what takes the corpus to 0
#678 the shebang header tail is the closing testtoken same rule, re-probed against ST

Same bug, fixed differently — with the reason

PR why this differs
#655 dedup flushed_ops The contract is the problem, not the duplicates. Corrections now carry the line they are for; dedup preserves a positional contract that the append ten lines away already contradicts.
#657 anchor replay-born branches to their replay line Same bug; here the replay carries the line it is re-reading, so everything that asks "which line is it now" has one answer.
#660 subtract bp.pop_count in the retain predicate The same predicate was asked inline in six places, four of them wrong; it is one function now. A fail: after certain pops silently did nothing before.
#665, #671 restore cleared scopes on deeper pops Same bug. Here a pop: N + set: unwinds one frame at a time instead of summing, which is what makes the deeper frames' clear_scopes restorable at all.
#668 skip the cur mcs Pop when the wrapper has embed_scope_replaces The frame records how much of its meta_content_scope was never pushed. Asking the stack later cannot tell a guest's own context from one pushed on top of the wrapper.
#670, #676 a non-topmost set target's clear_scopes, and what the trigger carries Same area. One predicate decides which of a set:'s targets may emit its Clear, in both phases, and a separate change settles what the trigger token of a multi-context set: carries. Probed rather than derived.
#673 prefer own over inherited in a multi-extends merge Same rule, decided by distance: a declaration one hop away beats one two hops away, and "later wins" only breaks a tie. Asked of ST in both parent orders.
#675 bypass the search cache on truncated searches Same bug at the same boundary.
#649 drop cur's meta_scope on a pop + embed match Covered by the pop: N + push:/branch:/embed: lookahead rule, which ST answers identically for all three.

Close as superseded — the failures they fix are 0 here

#658 #659 #662 #663 #666 #682 #686 #688 #689 #690

All are cross-line replay bookkeeping, or arbitration between an outer replay
and an inner one. This branch removes the arbitration instead: a fail that fires inside a replay does not
start a replay of its own — it hands back, and one driver runs the rounds in
order, so the reading that survives is the last one chosen rather than the last
loop left running. With that, pop: N + branch: unwinds on the retry as well as
on the first firing, and a branch that ran out on an earlier line hands the
position back inside the replay too.

Java is the file that chain is aimed at (issue #631), and it is at 0 here on
the same testdata — where #690 reaches 0 by forcing alternative 5 on a branch
point named class-members with an alt index below 5.

#680 (bump testdata/Packages) is also here, to v4206-2 rather than the v4202
that PR pins. #679 (drop Haskell from the baseline) is moot: Haskell is at 0,
so the row it removes is gone.

Close as not-a-fix — ST answers otherwise

PR what ST answers
#683 pop the deeper popped meta_scope before a pop: N + set: trigger ST keeps them. Three frames with distinct meta scopes: pop: 2 + set: gives the trigger source meta.outer meta.mid meta.tail keyword.trigger. #683 removes atoms ST has on that token. The variant that does drop them is pop: 2 + push: — that is #687, and it is in.
#684 collapse rule scope atoms duplicating popped meta_scope This one cites ST too (via sublime-mcp), so it is measurement against measurement rather than argument. Ours: one definition, four shapes on four lines — atoms adjacent, separated by the target's meta_scope, only the innermost restated, and the plain-push: nesting the PR itself says must not collapse — read back through ST's own syntax-test harness. ST keeps the repeat in all four. If the doubling on @ClassName.FixMethodOrder(...) is real, it comes from the unwinding rather than from a missing collapse rule: Java's annotation-qualified-identifier-name is at 0 here with nothing collapsed, once pop: N + branch: unwinds on the retry as well as on the first firing. The probe is four lines and re-runnable.

Not evaluated, and not in this branch


5. Reviewing it

Ten of the commits are one change each and are the place to start. Then, in
order: the public-api snapshot, the commit that brings the remaining changes
across together, the selector grammar, the first harness commit (#654, #678), the
corpus bump, the one test expectation that bump moved, the second harness commit
(#674), a comments-only pass, and last the KEYS_WE_USE one — which is a change
of its own with its own test, and landed after the rest.

Commit subjects carry the numbers as they stood at that commit — the one that
brings the bulk across says "34,874 → 141", which was true against v4050 with the
harness only half fixed. The final figures are the ones in §1.

titoBouzout and others added 21 commits August 12, 2026 19:10
`ScopeRepository::build` drops trailing empty components (`trim_end_matches('.')`)
and interns the rest, so `meta.annotation..identifier.java` — a doubled dot in
the middle — becomes three atoms with an atom named `""` between them and can
never match `meta.annotation.identifier.java`.

Sublime Text ignores empty components wherever they sit. Asked of it rather than
argued, since this looks exactly like a typo in the test file: a token scoped
`keyword.foo.bar` with one syntax-test assertion per shape, where
`keyword..foo.bar`, `.keyword.foo.bar` and `keyword.foo.bar..` all pass and
`keyword..zzz.bar` fails with "scope does not match" — so the selector is being
evaluated rather than waved through.

It is not only about a test file: a `.tmTheme` selector written with a doubled
dot silently matches nothing today, and nothing reports it.

`syntax_test_java.java` asks for `meta.annotation..identifier.java` nine times.
Haskell 50 -> 49 in the syntest summary here (both engines); the known-failures
files are updated.
Every other lookup in `SyntaxSetBuilder` resolves a reference to the last
definition that answers: `find_id` walks the syntaxes in reverse, and the
`name_to_index` fallback inside this same function is built by inserting every
name in order, so a later definition overwrites an earlier one. Only the
`path_syntaxes` loop took the first match.

The difference shows the moment one builder holds two copies of a package, which
is the supported way to let a user's `Packages` folder override the one that
shipped — `add_from_folder` twice, second copy wins. `TSX` is the file it was
found on: `extends: JSX.sublime-syntax` resolved to the *first* copy's `JSX`
while its other parent resolved by name to the second's, and the "parents derive
from different base syntaxes" check then refused the inheritance outright, so
every `jsx-*` context TSX inherits went unresolved.

`find_parent_index_takes_the_last_definition_at_a_path` is the repro, beside the
existing `find_parent_index_resolves_relative_paths`. The syntest summary does
not move — no package in `testdata` is loaded twice.
`resolve_extends` refuses an `extends` in three places: the parent could not be
found or the chain is circular, the parent's `version` differs from the child's,
and the parents derive from different base syntaxes. Only the first goes through
the pass at the end of the function that hides the child and gives it `main` and
`__start` — the pass whose own comment says why it exists, "so that
ParseState::new won't panic if this syntax is still accessed".

The other two `unresolved.remove(&child_idx); continue;` and carry on, so the
child reaches the set with whatever contexts it declared itself. For a
definition that is *nothing but* an `extends` that is none at all, and
`ParseState::new` indexes `context_ids()["__start"]` — so the first line parsed
with it panics.

Nothing in a curated package set can reach it, which is why no test that loads
one notices. A user's own folder reaches it with one file and two lines:
extending a shipped `version: 2` syntax without repeating the version.

The fix is the guard that already exists, applied to all three: the refusals
record the child in `broken`, and the final pass runs over `broken ∪
unresolved`. The "parent was not found" warning stays on the missing-parent set
only, since the other two have already warned with their own reason.

`extends_refused_for_a_version_mismatch_still_parses` is the repro; without the
change it panics in `ParseState::new`. The syntest summary does not move.
`MetadataItems` is `#[serde(rename_all = "camelCase")]` and the field is
`unindented_line_pattern`, which gives `unindentedLinePattern`. Every
`.tmPreferences` file spells the key `unIndentedLinePattern` — Sublime spells
this one that way and no other — so the value was dropped on load.

It is silent in both directions: the file still deserialises, because
`KEYS_WE_USE` has the spelling right and the other items are present, and every
other item in it is read. Only `ScopedMetadata::unindented_line` is affected,
and it could never answer `true` for any line of any language. Of the 145 scopes
in a metadata dump built from `testdata/Packages`, 0 carried this against 59
carrying an increase-indent pattern.

One `#[serde(rename)]`. `unindented_line_pattern_is_spelled_the_way_the_files_spell_it`
reads C++'s rules, which have one; it fails without the rename.
The reference is exact: "If a `fail` action specifies a `branch_point` that was
never pushed on the stack, or has already been popped off of it, it will have no
effect." It is the *action* that has no effect. The rule is a rule like any
other — its text is consumed, its `scope` applies, and the line carries on.

`handle_fail` answers an unresolvable name with `Ok(false)`, and `Ok(false)` is
what `parse_next_token` returns to say *stop parsing this line*. So a stray
`fail` threw away the colouring of everything after it on that line. CSS has
one: `- match: ';' fail: property-or-selector`, reachable outside any branch.

`can_rewind_to` is the test `handle_fail` already makes, asked before the rule
is routed there at all — in `parse_next_token`, and again in `exec_pattern`,
which is where the scopes are applied. An unresolvable `fail` falls through to
the ordinary path, where `MatchOperation::Fail` is already a no-op in
`perform_op`.

That has a second half, and `branch_fail_nonexistent_name` is what found it: a
rule let through to `perform_op` that changes nothing must not match empty, or
the parser sits at the same position forever. `search_with_end`'s
`does_something` already says that for `MatchOperation::None`; an unresolvable
`fail` is in exactly that position, so it says it there too. Without it,
`- match: '(?=;)' fail: nonexistent` hangs on the first line it meets — which is
that existing test's own definition.

`fail_for_a_branch_point_nobody_opened_has_no_effect` is the repro. The syntest
summary does not move: no assertion in this `testdata/Packages` reaches CSS's
stray `fail`.
One of the six behaviours listed under Compatibility, "Regex Capture Group
Order": in a version 1 syntax a capture group that begins before the last one
that applied is dropped. `build_capture_ops` applied every one of them whatever
the file declared, so every version 1 definition was getting version 2's
behaviour — quietly, since it is the *better* behaviour and nothing fails.

The documentation states the result the wrong way round, and that is the reason
this was asked of Sublime Text rather than implemented from the paragraph. Its
example is `(?:(x)|(y))+` over `yx` — group 2 matches the `y` at column 0, group
1 the `x` at column 1 — and the text says the `x` will not be scoped. ST 4206
answers the opposite: the `y` is bare and the `x` keeps `identifier.x`. So the
rule is not about the group's number at all; the groups are read in
capture-index order and one that starts behind the cursor is skipped.

Which cursor took a second question, because two readings survive that example
and they disagree about the commonest case in the format. Against the last
applied group's *end*, `((a)b)` loses its inner group — group 1 is [0,2) and
group 2 begins at 1 — and every nested capture in every version 1 definition
stops being coloured. Against its *start*, nesting is untouched. ST keeps the
inner group, so it is the start. `cab` under `(?:(a)|(b)|(c))+` is the third
case: the cursor is carried from group to group, so the `c` at column 0 is
dropped after groups 1 and 2 have applied.

The mapping is in the order the YAML hash was read, so the version 1 walk sorts
by capture index rather than trusting it.

`version_1_capture_that_goes_backwards_gets_no_scope` is the repro, with all
three cases and version 2 beside each. The syntest summary does not move: no
file in `testdata/Packages` exercises it, which is what an audit against the
specification is for.
Compatibility, "Multiple Target Push Actions with `clear_scopes`": a
`push: [context2, context3]` where both carry `clear_scopes: 1` takes *two*
atoms off in a version 1 syntax, before either `meta_scope` goes on. The
documentation's example scopes `abc` as `meta.ctx2 meta.ctx3 identifier`, with
the file's own `source.lang` cleared away, where version 2 interleaves them —
clear, push, clear, push — and leaves `source.lang meta.ctx3 identifier`. This
emitted version 2's interleaving at both versions.

The two phases answer differently, and Sublime Text 4206 says so: the trigger
token sees the sum, and the column *after* it sees version 2's interleaving —
`source.lang meta.ctx3`, not `meta.ctx2 meta.ctx3`. So the initial phase clears
the sum and pushes the meta scopes over it, and the non-initial phase puts that
back with a `Restore` and applies them one context at a time. Getting only the
first half right would leave every line after the push inside a scope the file
had cleared.

`summed_push_clear` is the amount, and `All` swallows the rest, being already
everything there is.

**This changes an existing test's expectation, deliberately.** The v1 half of
`v2_push_multiple_clear_scopes_each_applies` asserted two `Clear` ops, one per
context, on the reading that v1 and v2 are the same rule here. They are not:
the documentation states the difference and ST answers as it states. The v2 half
is untouched, and the v1 half now asserts the sum for the trigger followed by
the interleaved pair for the text after it.

`version_1_push_clears_the_sum_for_its_trigger_alone` is the repro, both phases
and both versions, on the documentation's own example. The syntest summary does
not move.
Compatibility, "Embed Escape Match and Meta Scopes": the text an `escape:`
matches gets neither the `meta_scope` nor the `meta_content_scope` of the
context the `embed:` fired from, in a version 1 syntax. `exec_escape` popped
back to that context and left it on the stack for the escape's own captures,
which is version 2's behaviour.

The documentation's example is a `'`-delimited embed inside a context carrying
`meta.group` and `meta.content`: the closing quote comes out `punctuation.end`
alone at version 1 and `meta.group meta.content punctuation.end` at version 2.
ST 4206 agrees at both, and adds the part the sentence does not say — it is
*only* the token. The column after the escape is back inside both scopes.

So they come off before the escape's captures and go back on after it, which is
the shape a `set:`'s meta scopes already have around their trigger. The version
read is the host's: the pop loop above has just returned to the frame the
`embed:` fired from, so `self.stack.last()` is that frame.

`version_1_embed_escape_is_outside_the_context_that_holds_it` is the repro, both
versions and the column after the escape. The syntest summary does not move.
The reference's own example under "Using pop with another action" is a
`paragraph` context with a `meta_scope` whose ```py fence fires `pop: 1` and
embeds Python. Sublime Text scopes the closing fence with that `meta_scope`;
this had it bare.

Nothing in the reference says so either way — it is the cross-product of two
entries — so ST 4206 was asked, and the columns either side of the escape are
what make this a *retarget* rather than a deferral. For `p<a>t`: the `p` that
pushes has `m.para`, the `<` firing `pop: 1 + embed:` does not (the pop is a
lookahead, which the reference does say), the embedded `a` does not, the escape
`>` **does**, and the `t` after it does not.

So the frame is off the stack for everything except its own escape, and by the
time the escape fires nothing on the stack can say what it was. `EscapeEntry`
carries a `holder` — the frame the `pop:` unwound, remembered at the pop, with
the flag the pop loop in `exec_escape` already computes for whether its
`meta_content_scope` was ever pushed — and the escape pushes its meta scopes
before its captures and pops them after.

Version 1 wants none of it: there the escape is outside the context that holds
it altogether, and the holder is off the stack already.

`pop_n_embed_escape_wears_the_context_it_left` is the repro. It asserts every
column but the trigger: ST has no `m.para` there, because the pop of a
`pop: N + embed:` unwinds before the match takes its scopes, and that is a
separate rule and a separate PR (trishume#687).

The syntest summary does not move.
`ParseLineOutput::replayed` carries the ops of lines the parser has already
handed back and has since decided differently about: a `fail` two lines later
rewinds to a branch point above and re-parses what was buffered. They are handed
out **positionally** — the i-th entry for the i-th buffered line — and, ten lines
away in the same function, a second run is *appended* when a second cross-line
`fail` fires on the same `parse_line` call, with the comment "in case multiple
cross-line fails fire on the same parse_line call". Both cannot be right, and
what happens is that one line's ops land on another line's text.

`flushed_ops` and `replayed` are now `Vec<(usize, Vec<(usize, ScopeStackOp)>)>`,
each correction tagged with the parser's own 1-based `parse_line` count, worked
out where the replay happens: the current line, less the buffered lines before
it, plus the offset into that buffer. Appending then becomes harmless — a later
correction for a line replaces an earlier one — and no consumer has to
reconstruct which lines were buffered.

`examples/syntest.rs` is that consumer, and it is where this shows. It paired
`replayed` with the tail of its own line buffer, and **four of the six files
that panic in the harness today panic there**, in
`ScopeRegionIterator::new(replayed_ops, &record.line_text)` at `easy.rs:289`
— "end byte index N is out of bounds for string of length M", which is precisely
ops from a longer line being walked over a shorter one. With the corrections
keyed by line, those four run to completion: Java, TypeScript, Python and Bash
(and Zsh) stop crashing the harness.

That is what moves the known-failures files, and it moves them *up*, because a
crashed file was recorded as `1`:

  Java              1 -> 33243     (was: panic)
  Zsh               1 -> 604       (was: panic)
  Bash              1 -> 285       (was: panic)
  TypeScript        1 -> 230       (was: panic)
  Python            1 -> 66        (was: panic)
  C# GeneralStructure  3 -> 2      (a real correction, applied to the right line)

Those five numbers are what those files have always failed; the table was
recording the crash instead. Both known-failures files are updated, and both
engines agree.

The sixth panic is not this one and is still there: Markdown, inside the parser
itself ("start byte index 2 is out of bounds for string of length 1"), which is
the out-of-bounds trishume#657 is about.

`a_cross_line_fail_names_the_lines_it_corrects` pins the tag on three lines: `d`
opens a branch, `q` is buffered, `z` fails it, and the corrections must be for
lines 1 and 2 and say so.
`ParseLineOutput::replayed` is now `Vec<(usize, Vec<(usize, ScopeStackOp)>)>`.
It is a breaking change to a field added in the same unreleased cycle as the
cross-line replay itself, and it is the point of the change before this one: a
correction that does not name its line cannot be applied to the right one.
…,874 -> 141

The ten commits before this one are the changes that stand on their own, ported
by hand into upstream's shape. This one brings the remaining twenty-four over
wholesale, so that `src/` here is byte-identical to the fork it came from
(`text-editor`'s `vendor/syntect`, itself vendored from 4aa7803) and the two
trees can be diffed for drift with `diff -r`.

What arrives with it, by file:

- `regex.rs` — the literal prefilter. Every pattern's leading literals are taken
  off with `regex-syntax` and searched for with `memchr` / Aho-Corasick before
  the engine is asked; a pattern whose literal is absent from the rest of the
  line is not searched. It is a *necessary condition only*, and on a debug build
  every rejection is put to the engine it skipped, so a disagreement between
  `regex-syntax` and Oniguruma fails there rather than colouring something
  wrong. `Cargo.toml` gains `memchr` and `aho-corasick` as optional deps in the
  `parsing` feature.
- `parser.rs` — the branch/replay machinery, the `pop: N` family, the version
  gates, `clear_scopes` ordering, the escape rules, the zero-width guards.
  `MatchOperation::Push` becomes `Push { ctx_refs, pop_count }`: `pop: N + push:`
  was read as a bare `Pop(N)` and the push was dropped, and the trigger's scopes
  differ from a `set`'s (the pop is a lookahead). That is the one breaking change
  to the public API here, and the snapshot is updated.
- `syntax_set.rs`, `yaml_load.rs`, `syntax_definition.rs` — `extends` merged by
  distance rather than by list order, a child's inherited `main` carrying its own
  scope and not its parents', prototypes not followed through an `embed:`,
  `captures:` narrowed to consuming groups after the regexes are final.
- `dumps.rs` — `dump_uncompressed_to_writer`, five lines, for callers that write
  a dump behind their own header.

Every one of them is written up at its site, and the fork's `FORK.md` carries
the same list with the probe and the per-language number behind each.

`assets/*.packdump` are regenerated (`make packs`): the serialised `SyntaxSet`
changed shape with `MatchOperation::Push`, and the shipped dumps could not be
read at all — `cargo test` aborted on a 7-exabyte allocation before this.

Upstream's own corpus, `make syntest`, both engines, no panics anywhere:

              before      after
  ASP             53          0
  Batch File      74          0
  C# General       3          0
  Haskell         50          6
  Java        33,243          0   (was a harness panic, recorded as 1)
  JSP             44          0
  TypeScript     230          0   (was a harness panic)
  LaTeX           76          0
  Markdown         1          0   (was a parser panic — trishume#657's)
  PHP              1          0
  Python          66          0   (was a harness panic)
  Python strings   1          0
  Rails HAML      65          0
  Rails ERB       23          0
  Bash           285         62   (was a harness panic)
  Zsh            604         15   (was a harness panic)
              ------      -----
              34,874        141

What is left is C#11 (35), C# Generics (3), git_config (17), Bash (62), Zsh (15),
Haskell (6) and four one-assertion files. The fork measures against a *newer*
`Packages` than the v4050 pinned here and is at zero on all of them, so these
are worth re-reading against a newer corpus before they are read as parser bugs.

`examples/syntest.rs` needed one more fix to survive its own corrections: after
folding them in it now re-walks the file from the top rather than from the
earliest corrected line. A correction is the whole of a line's ops, and a
`Restore` in one can be paired with a `Clear` emitted on a line that was not
corrected — so a walk that starts in the middle meets a `Restore` with nothing
cleared, which is what `syntax_test_java.java` did, panicking with
`NoClearedScopesToRestore`. It costs a re-walk per batch of corrections, and the
whole corpus runs in 41 s.

Two of upstream's own tests change their expectations, both deliberately and
both explained at the test: `can_parse_backrefs` (an embedded `SQL (Basic)`
arrives with one scope of its own, not its ancestors' chain) and the v1 half of
`v2_push_multiple_clear_scopes_each_applies` (a version 1 multi-context `push:`
clears the sum, which is what the reference says and what Sublime Text does).

The comments still carry `TEXT-EDITOR FORK:` markers and a few paths into the
fork's own tooling — that is how the fork finds its change sites, and stripping
them is the last pass before any of this is opened as a PR.
`ScopeSelectors::from_str` splits on `,` and `|` and stops. There is no `&`, no
grouping and no error, so `(source.c | source.c++ | source.objc | source.objc++)
& comment.block` parses into four selectors of which one is a bare ` source.c++ `
— and `does_match` answers `Some(MatchPower(2.0))` for the stack `[source.c++]`,
a file that is not in a comment at all.

It is not academic. `Packages/C++/Indentation Rules - Comments.tmPreferences` is
that selector and the only thing it carries is `unIndentedLinePattern: .`, so
`metadata_for_scope(&[source.c++])` hands a caller "every line of this file is
outside the indentation".

The grammar, with the precedence Sublime Text gives it:

- `,` and `|` — union, and the loosest
- `&` — conjunction, tighter than union
- ` -` — exclusion, tighter still, and written with a space in front, because a
  scope name may contain a dash (`entity.name.function.post-blit`)
- `( … )` — grouping

An expression is flattened to a union of simple selectors, which is what
`ScopeSelector` holds and what `does_match` is written against. Distributing is
also what makes `a - (b & c)` expressible: it is `(a - b) | (a - c)`.

**The precedence and the scoring were asked of the application, not read.**
`sublime.score_selector(scope, selector)` answers with a number, so it settles
matching and ranking at once, and three of its answers decided the design:

- `(source.c | source.c++) & comment.block` scores 0 on `source.c++` and 32 on
  `source.c++ comment.block`. A conjunction is not a descendant path.
- `source.c | source.c++ & comment.block` scores 4 on a bare `source.c`, so `|`
  binds looser than `&`.
- On `source.c++ comment.block`, `source.c++` scores 4, `comment.block` 32, the
  path `source.c++ comment.block` 36 — the two added — and the conjunction 32,
  the larger of the two, in either order. So a conjunction's score is the
  strongest of its operands, not their sum.

`ScopeSelector` gains `conjuncts: Vec<ScopeStack>`, and `extract_scopes` and
`extract_single_scope` account for it. An *empty* exclusion still excludes
everything, which `empty_stack_matching_works` pins and which the distribution
therefore keeps rather than dropping.

`assets/default.themedump` is regenerated: it is `bincode` over this type, and
without `make themes` every test that touches a default theme aborts the process
on a nonsense allocation.

`conjunction_and_grouping_answer_as_sublime_text_does` is the repro and carries
ST's table. `cargo test` passes whole; `make syntest` and `make syntest-fancy`
do not move.
… line

Two rules Sublime Text has that this harness does not, found by porting a fork
onto this tree and then asking ST what it makes of the files that came out
failing. Both were reported against ST's own harness years ago (trishume#654, trishume#678);
this is the example here having them too.

**The closing testtoken is everything after the quoted path, alphabetic or
not.** The header regex restricted it to punctuation, with a comment saying that
an alphabetic tail like the `clojure` in `#! SYNTAX TEST "…" clojure` must not
be "mis-captured". It is the other way round: ST strips such a tail exactly as
it strips `-->`, and a multi-word tail (`dotnet run`) is one token. Asked
directly, with a `foo` tail against a selector `keyword.foo.bar foo`, which
passes, and against one naming a scope that is not there, which fails. Until now
every assertion in `syntax_test_shebang.clj` and `syntax_test_shebang.d`
compared a selector ST would have clipped.

**A column past the end of the line under test is the corresponding column of
the next row.** `text_point(row, col)` overflows a column beyond its row into
the row below, and the syntax tests inherit that. This harness reused the
target's last scope for every such column instead — the consumed newline,
carrying whatever the end-of-line pop chain left there. Probed three ways: a
`^^^` run three columns past a two-character line passes for `comment.line`,
which is the scope of the assertion line below it, fails for
`punctuation.definition.comment`, which is only that line's first column, and an
empty target line has all of its assertion's columns compared rather than none.

`process_assertions` now takes the runs of the following line and indexes them
at `column - end_of_line`. Having them means parsing that line before its own
assertions are judged — a clone of the parser and one extra parse of one line
per target — and the replay path builds the runs of every buffered line in a
first pass for the same reason.

Neither rule is about the parser, and the corpus says so. Nothing regresses,
nothing new appears, no panics:

  Git Formats/git_config      17 -> 0
  ShellScript/Bash            62 -> 26
  Clojure/clojure.clj          1 -> 0
  Clojure/shebang.clj          1 -> 0
  D/shebang.d                  1 -> 0
                             ---------
                             141 -> 85

ST passes all 2,025 assertions of the four files that reach zero, and the parser
on this branch passes all 11,654 of them through a different harness — which is
what said the harness was the thing that differed. Both known-failures files are
updated and both engines agree.
The submodule was pinned at `1ba99a47` (v4050-1502), April 2026. The
`known_syntest_failures*.txt` this branch has been regenerating are therefore a
measurement against definitions four hundred commits old, and a good part of
what was left in them was the definitions rather than the parser — which is
exactly what asking Sublime Text about those files said: ST fails the stale ones
harder than this branch does.

So the corpus moves to `69457400` (v4206-2), and the numbers become about the
parser again:

                                   v4050            v4206
  this branch, total                  85               47
  C#11                                35                0
  C# Generics                          3                0
  Haskell                              6                6
  Bash                                26               26
  Zsh                                 15               15

C#'s two files carried every one of the 38 that went: their definition has moved
4,549 lines since v4050 and `syntax_test_Generics.cs` no longer exists. Nothing
regressed, no file appeared, and both engines agree — `known_syntest_failures.txt`
and `known_syntest_failures_fancy.txt` are byte-identical to each other now.

For the record, master on this same corpus is **414 across 21 files, with eight
panics** — seven in the harness (`easy.rs:289`) and one in the parser — against
this branch's 47 across three files and none.

What is left is Haskell 6, Bash 26 and Zsh 15. Nineteen of Bash's are not parser
failures at all: fifteen are `${parameter#pattern}` inside a comment read as an
assertion whose selector is `pattern}`, and five are the prose
`] should close the conditional`. That is trishume#674, which is not in this branch.
The corpus bump in the previous commit moved C's preprocessor scopes:
`#ifdef` / `#elif` / `#endif` are `keyword.control.directive.conditional.{if,
elseif,endif}.c` in Packages v4206 and were `keyword.control.import.c` at v4050.
The test reads `testdata/Packages`, so its expectations are about whatever
revision the submodule points at.

Nothing about the parser changed here; the three scope names in the test did.
The comment at the top of it says which revision they belong to, so the next
bump has somewhere to start.

I committed the bump before running `cargo test` — this is that oversight
corrected rather than folded away.
…tion — and the corpus reaches zero

The third harness rule, and the one that turns out to be holding up the last
number: `get_line_assertion_details` recognised any line where the testtoken
appeared, wherever it appeared. ST runs **zero** assertions on a line with
anything but whitespace in front of the token, so markers found after source text
are a coincidence.

Bash's own corpus is where it shows. `: ${#^pattern}` contains the testtoken, so
the harness read that *source* line as an assertion whose selector is `pattern}`
— and, having decided it was an assertion, stopped treating it as the line the
assertions below it are measured against. Both halves were producing failures:
five columns of nonsense selectors, and every real assertion under those lines
compared against the wrong text.

This is trishume#674, and with it the whole corpus passes:

                          v4206
  before this commit         47   (Haskell 6, Bash 26, Zsh 15)
  after                       0

`known_syntest_failures.txt` and `known_syntest_failures_fancy.txt` are now empty
of `FAILED` rows on both engines.

**It is not passing by skipping.** Bash runs 40,789 assertion columns after the
change against 40,793 before — four fewer, which are exactly the coincidental
ones. For scale, ST runs 11,065 assertions of its own on that file and fails
none of them, which is what said this residue was the harness rather than the
parser in the first place.
The comments carried `TEXT-EDITOR FORK:` on every change site — 129 of them —
because that is how the fork finds its own diff by grep. Upstream has no use for
it, so it is gone, along with the things it dragged in: paths into the fork's
probe tooling (`tools/sublime-oracle/probes/…` becomes the probe's own name,
which is all a reader needs), references to `change N` in a document that does
not exist here, and four sentences describing the downstream application rather
than this library.

Comments only. No line that is not a comment differs from the commit before this
one, `cargo test` is 194 + 8 + 1 + 13 as before, and `make syntest` reports no
failing file.

The prose that was after each marker stays as it was — it is the reasoning, and
several of those paragraphs are the only record of what Sublime Text answered
when it was asked.
… read them

`indentSquareBrackets`, `preserveIndent` and `indentOnPaste` are not in that
list, and a key that is not in it is discarded while the raw entries are merged
into a `Dict` — before `MetadataItems` is deserialized at all. So a field for
one of them could not have worked: it would have read `None` for every file
that carries the key, and nothing anywhere would have looked wrong.
`indentParens` is the odd one out — in the list, on the struct, and simply
never surfaced on `ScopedMetadata`.

They are the half of the indentation rules that is not a pattern. A pattern
says "a line that looks like this opens a block"; a flag says "an unclosed
bracket at the end of a line does", which no pattern can express, because it
has to count. In `sublimehq/Packages` nine files carry `indentSquareBrackets`
and five `indentParens` — Go, Perl, Gomod, Shell and Zsh, every one of them
turning the behaviour *off* for a language whose own `increaseIndentPattern`
already says when a bracket matters. `preserveIndent` (12 files) and
`indentOnPaste` (1) are the same shape for text that arrives in a block rather
than being typed: whether a reindent may touch the indentation it came with.
The twelve are heredocs and block comments, which is the whole of what that
key is for.

Three names, three `Option` fields, four accessors. No parser code, and the
cost is at load only — this is read once per set.

**`Option<bool>` and not `bool`, because the default is a file and not a
value.** The reference gives each key a sentence and never says what a
definition that mentions none of them gets, which is the only question that
matters when every file that carries one is turning something off. Asked of
Sublime Text 4206 rather than argued: pressing Return at the end of `foo(a,`
and `x = [1,` in nine languages, ST indents after the paren and not the square
bracket in Rust, whose `.tmPreferences` is two brace patterns and nothing else
— and indents after neither in plain text, which has no `.tmPreferences` at
all. The answer is that ST's own `Default` package ships one scoped to bare
`source` carrying `indentParens: true`, and another scoped to `comment`
carrying `preserveIndent: true`. That package is closed and is not in
`sublimehq/Packages`, so nothing here can ship it; what this library can do is
report what a file said and leave "and nobody said" distinguishable from "no".

`the_indentation_flags_are_read` is the test, over `testdata/`'s own files: Go
turns all three off, JSON turns one on — so `Some(false)` cannot be a
deserializer answering `false` for everything — and Rust mentions none of them.

`assets/default_metadata.packdump` is regenerated (`make packs`). It is a dump
of `Metadata`, so three new fields change its shape: without the regeneration
every `load_defaults` with the metadata feature on comes back
`InvalidTagEncoding(6)`, which is fourteen tests, none of which fails when it
is run on its own. The two syntax packdumps `make packs` also writes are left
as they are — what they disagree with the tree about is not this.
…s as the files have them

`RUSTDOCFLAGS='--deny warnings' cargo doc --document-private-items` is a
CI job here, and it failed on this branch: `Regex`'s documentation linked
`[`Prefilter`]` and `ScopeSelectors::from_str` linked `[`SelectorExpr`]`,
both private, both added by this branch. Rustdoc resolves them only
because that run passes `--document-private-items` and says so. They are
plain code spans now and the job passes.

And the counts beside `KEYS_WE_USE` were taken with `grep`, which counts
a key named inside a comment (`interacts with indentSquareBrackets`, in
JSON's own `decreaseIndentPattern`) and a whole block commented out
(PHP's heredoc-end file). Parsed as plists, `testdata/Packages` at this
pin has seven files carrying `indentSquareBrackets` and four
`indentParens`, not nine and five — nine and five is what the count comes
to with third-party packages in — and none carrying `indentOnPaste` at
all. "Every one of them turns the behaviour off" was wrong too: C++ and
JSON turn square brackets on, and nine of `preserveIndent`'s twelve say
`false` at the language's own scope, which takes back the default the
`Default` package sets rather than restating it.

Comments only; no behaviour changes. `cargo test --features metadata`
201 + 8 + 1 + 14, `make syntest` and `make syntest-fancy` still report no
failing file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`cargo fmt -- --check` is a CI job here and it failed on this branch: 29
hunks, all of them in lines this branch added — upstream's own code was
already clean. Whitespace only.

`cargo test --features metadata` 201 + 8 + 1 + 14, `make syntest` and
`make syntest-fancy` both report no failing file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant