fix(core): stop chars ranges leaking across patterns and shared tokens - #276
Conversation
Two defects made ranged char highlights (`/foo/2`, `/foo/#a`) affect the wrong characters, per rehype-pretty#169. 1. Range/pattern misalignment (index.ts). `charsList` always received an entry, but `charsListNumbers` (the parallel ranges array) was only pushed when a numeric range was present. An id-only annotation like `/foo/#a` therefore left the arrays misaligned, so a later pattern inherited the previous pattern's range. Always push a ranges entry (empty array when there is no range) to keep the arrays index-aligned. 2. Ignored occurrences over-consumed their token (chars/*). When a range excluded an occurrence, `splitElement` returned the whole node unsplit, so the entire containing token was marked visited. Any other pattern living in the same token on that line (e.g. `Length` inside `getStringLength` when `/get/1` is range-ignored on later lines) could then never match. Let ignored occurrences split like any other so only the matched part is marked visited; the now-unused `ignoreChars` plumbing is removed from `getElementsToHighlight`/`splitElement`. Result: `/get/1 /Length/` now highlights `Length` on every line while `get` stays on its first occurrence, and `/get/#a /Length/2` highlights `get` everywhere while `Length` is limited to its second occurrence. Snapshots: `highlightedMultipleCharsRange` gains the two occurrences that were previously dropped; the other three updated snapshots are unchanged in what they highlight (identical <mark> sets) and only gain cosmetic span splits on ignored, unhighlighted tokens. Added a `charsRangeInheritance` fixture covering both scenarios. This addresses the range-inheritance part of rehype-pretty#169. Highlighting a pattern that is a substring of another highlighted pattern (`/getStringLength/ /get/`) is a separate nested-highlight feature and is out of scope here. Refs rehype-pretty#169 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 279702a The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
PR reviewThe range alignment and ignored-occurrence splitting are mechanism-correct and adequately covered. No source-code regression survived review; only release metadata is missing. Docs (1)1. 🟠 Add a patch changesetLocation: charsListNumbers.push(range ? rangeParser(range) : []);This fixes published character-range behavior but has no changeset. Failure scenario: The range-inheritance fix merges without triggering a new Fix: Add a changeset declaring VerdictApprove after nits - the implementation is sound, with only the required release entry outstanding. 🤖 Review generated with Codex |
PR reviewBoth mechanisms in this PR are correct and I verified each against Bugs (1)1. 🟠 A range-ignored occurrence still blocks later patterns when no split is neededLocation: if (ignoreWord) {
if (element.properties) {
element.properties['rehype-pretty-code-visited'] = '';
}
return;
}
Verified on this branch and on ```js /getString/1 /String/
const getStringLength = 1;
const getStringWidth = 2;
```Marks produced: This is pre-existing rather than a regression, and the real fix — a per-pattern skip marker distinct from Failure scenario: Fix: Nothing to change here — this PR is a strict improvement. Worth opening a follow-up issue so the remaining case is not mistaken for fixed by #169. Docs (1)1. 🟠 The changeset does not mention the HTML output churnLocation: fix: stop character-highlight ranges leaking across patterns, so an id-only annotation like `/foo/#a` no longer makes a later pattern inherit the previous pattern's range, and a range-ignored occurrence no longer consumes the whole token it lives in (#169)Splitting ignored occurrences means tokens that previously stayed intact now emit sibling spans carrying identical styles: <!-- before -->
<span style="color:#79B8FF">rotcar</span>
<!-- after -->
<span style="color:#79B8FF">rot</span><span style="color:#79B8FF">car</span>Six committed result fixtures change for this reason. The rendering is unaffected, but the emitted HTML is not. Failure scenario: A consumer with HTML snapshot tests over their rendered docs takes a patch bump and gets diffs across every code block that uses a character range, with a changeset that gives no hint the markup changed. Fix: Add a sentence noting that ignored occurrences are now split into separate spans, so the emitted HTML changes even where the visible highlighting does not. VerdictApprove after nits - both fixes are verified correct against 🤖 Review generated with Claude Code |
|
Added the patch changeset — the only outstanding item, since the review found nothing to change in the source. The entry covers both halves of the fix: the range/pattern misalignment that let an id-only annotation such as Nothing else in this push, so the snapshots stand as reviewed and the fixture suite is still green. |
|
Before this year these would've been merged already 😅. But now models keep finding issues that the back-and-forth takes forever. PR reviewThis contains two regressions missed by the existing 38-test suite. One can corrupt the rendered code itself and should block merging. Bugs (2)1. 🔴 Ignored matches can corrupt the rendered code textLocation: charsSoFar += overlap;
toWrap.push({
element: newElement,
index: updatedIndex,
});Splitting an ignored occurrence exposes its fragments to subsequent scans. The matcher can retain partial state across a visited or non-continuing sibling and return non-contiguous elements. Failure scenario: ```js /con/2
coconnection;
```There is only one Fix: Treat visited or non-continuing siblings as hard boundaries, reset partial-match state across gaps, and ensure 2. 🟠 Failed synthetic matches advance the range counterLocation: options.counterMap.set(id, (options.counterMap.get(id) || 0) + 1);
const ignoreChars =
currentCharsRange.length > 0 &&
!currentCharsRange.includes(options.counterMap.get(id) ?? -1);
const elementsToWrap = getElementsToHighlight(element, chars, startIndex);
if (elementsToWrap.length === 0) break;After an ignored match is split, recomputing Failure scenario: ```js /lo/2
balloon;
lo;
```The first real Fix: Increment VerdictRequest changes - ignored-match splitting can corrupt emitted code and miscount ranged highlights. 🤖 Review generated with Codex |
Splitting an occurrence a range excludes leaves the token in pieces around a node the scanner has to skip. Two things then went wrong. The remaining text was recomputed by dropping the excluded node, which joined its neighbours: 'bal' + 'on;' reads as 'balon;' and contains an occurrence of 'lo' the code never had. That phantom either advanced the range counter past a real later occurrence, or led the matcher across the gap and returned non-adjacent elements, which wrapHighlightedChars then spliced as if they were consecutive -- deleting and duplicating text. Keep a boundary in place of an excluded node so fragments cannot spell a match across it, count an occurrence only once it resolves to elements, abandon a partial run as soon as it stops being contiguous, and read the positions back from the tree before splicing.
`/con/` on `coconnection` renders as `coconneconon;` on master: the highlighted `con` is dropped when the remaining text is recomputed, `co` and `nection;` join into a phantom second occurrence, and wrapping it splices siblings that were never matched.
|
Both reproduce exactly as described, and both trace back to one line: the remaining text is recomputed by dropping excluded nodes, which joins their neighbours. Once Four changes:
Both cases from the review are in the fixture, and I confirmed they fail without the fix. One thing worth flagging: the corruption is not only reachable through this PR. On ```js /con/
coconnection;
```renders as To check I had not traded these two for others, I ran 300 pattern × code combinations through Suite is green (38/38), snapshots unchanged apart from the new fixture, biome and tsc clean. I also added the sentence about markup churn to the changeset — that was still outstanding from the previous round, and I had only said I'd added the changeset itself. The residual leak you prototyped ( |
Summary
Addresses the range-inheritance part of #169: ranged char highlights
(
/foo/2,/foo/#a) leaking onto the wrong characters.1. Keep ranges aligned with patterns
charsListalways got an entry, but the parallel ranges array was pushed onlywhen a numeric range existed. An id-only annotation such as
/foo/#athereforemade the next pattern inherit its range.
The parser now always pushes an aligned range entry, using an empty array when
there is no numeric range.
2. Count excluded occurrences without changing the token tree
The matcher tracks occupied source offsets separately from the rendered HAST. A
range-excluded occurrence still increments the counter and prevents overlapping
patterns from claiming the same characters, but it is not split into additional
spans.
The previous rescan-based matcher could join text around visited fragments,
fabricate matches that were not present in the source, miscount later ranges,
and splice non-contiguous siblings as though they were adjacent. Matching from
stable source offsets removes that class of source corruption and makes repeated
cross-token matches exact.
Selected matches are grouped by their overlapping top-level token ranges, then
each group is applied from right to left. This avoids repeatedly rescanning the
whole line while
onVisitHighlightedCharsremains in the originalpattern/occurrence order.
3. Preserve transformer-owned markup
A partial match contained within a custom or transformer-generated element now
inserts the highlight at the matching text node instead of cloning semantic
ancestors. Ordinary one-text Shiki spans use a smaller linear split path: the
matched fragment reuses the original node, leftover fragments do not duplicate
its
id, and array-valued properties are copied so callback mutations cannotleak into unhighlighted fragments.
Cross-token matches may also include a transformer-owned boundary unchanged when
that boundary is selected in full. A boundary that would need to be partially
split remains intentionally unsupported.
Result
/get/1 /Length/(2 identical lines)LengthLengthon both lines;getonly 1st/get/#a /Length/2geteverywhere;Lengthonly 2nd/con/on repeated text such ascoconnection/a/50000over 50,000asThe first row is the exact example from the issue.
Regression coverage
The final adversarial pass checked 20,000 randomized span-tree cases covering
IDs, property isolation, overlaps, sparse/shared counters, callbacks, source
preservation, and mark ordering, with zero mismatches.
Performance
The tables below were collected on
608e568, the batching revision immediatelybefore the merged head (
279702a). That final follow-up only extendedcross-token application to boundaries selected in full; affected cases were
rebenchmarked with no material regression. The matrix compares current
master,the original PR head (
58d068d), and608e568across 41 direct highlighter +HAST-serialization cases and 10 public Shiki pipeline cases. Each row reports a
warmed median.
Public Shiki pipeline matrix (10 cases)
master58d068d608e568aabeforea, 8k charsRepresentative direct highlighter matrix
master58d068d608e568abmatchesabcdmatchesabeforeaa, 10k charsThe remaining direct cases covered empty/unmatched patterns, dense and sparse
sibling spans, whole-token matches, long cross-token groups, nested tokens,
direct/mixed text children, comments, emoji and combining Unicode, both overlap
orders, duplicate and 100-pattern workloads, multi-line counters, and callback
mutation. Expected counts and output sizes matched wherever the behavior is
supported.
Rows marked
0 marksare not speed wins formaster: it did not produce therequested highlight, so those timings perform less work and are not directly
comparable.
Ordinary real-world documentation corpora were effectively neutral (generally
within about ±2% benchmark noise). The large wins occur in dense or repetitive
character-annotation workloads, where avoiding repeated tree rescans and
fragmentation improves performance by roughly 3–115× in the measured cases.
Scaling remained close to linear; no quadratic explosion was observed.
The intentionally removed pathological shapes were measured too rather than
hidden: 1,000 matches inside a single 12-level wrapper or semantic link are
slower. A cross-token match that would require partially splitting a nested
transformer-owned boundary remains unsupported; a boundary selected in full is
preserved. Those combined shapes were excluded to keep the realistic Shiki path
small and linear.
Bundle size
The production-build table was measured at
608e568against the samemaster:master608e568The final boundary-preservation follow-up added 56 minified bytes / 23
minified+gzip bytes in the downstream measurement. The batching revision was
also 650 raw bytes / 102 gzip bytes smaller than the previously pushed broad
recursive correctness fix (
f487d89), which was removed in favor of the focusedShiki-span path.
Validation
pnpm test— 56/56 passing on the merged head.The changeset remains a patch because this is a bug fix.
Out of scope
Highlighting a pattern that is a substring of another highlighted pattern
(
/getStringLength/ /get/— the other half of #169) is a distinctnested-highlight feature and is intentionally not addressed here, so this PR is
marked
Refsrather thanCloses.