Skip to content

Declarative $inner selectors and a token overlay helper - #4110

Open
LeaVerou wants to merge 2 commits into
v2from
claude/compassionate-brahmagupta-94vewq
Open

LeaVerou wants to merge 2 commits into
v2from
claude/compassionate-brahmagupta-94vewq

Conversation

@LeaVerou

Copy link
Copy Markdown
Member

Follow-up to #4105 / #4107, per the review discussion there: meta-languages now declare which parts of their token tree are code in the inner language, instead of a custom tokenizer or placeholder tricks.

// diff.js — the before and after versions of the code, each highlighted as one whole
$inner: { select: ['unchanged, deleted, diff', 'unchanged, inserted, diff'] }

Selectors

$inner is either a grammar reference (as before) or { language?, select? }. A selector names containers, separated by commas: token names (matched by type or alias, since both become classes, so deleted covers deleted-sign and deleted-arrow) and :text for the unmatched text. The plain strings directly inside the selected containers are concatenated in document order, tokenized as one whole with the inner language, and the tokens are put back where the strings were.

  • Default selector is :text, which is exactly what templating languages need, so they are unchanged (inner: markup and nothing else).
  • 'foo, :text' highlights foo's content together with the unmatched text as one document; ['foo, :text', 'bar, :text'] does that twice, separately, later selectors winning where they overlap.
  • Inner tokens that span several strings of the same container stay whole, with the container's other tokens (template tags, diff prefixes) inserted into them by position. Inner tokens that span different containers are split.
  • :text is a pseudo-name because text is already a token in pug, haml, mermaid, plant-uml, textile and inform7; token names can never contain :.

diff is now fully declarative (66 lines, the original grammar plus the $inner line) and better than the old plugin: the two selectors are the before and after versions of the file, so a comment opened on an unchanged line and closed after a change is highlighted correctly on the deleted and the inserted lines. tests/core/embedding.js covers that; the five existing snapshots (diff:javascript, django:css, diff:django:css, php:none, …) are byte-identical to v2.

No placeholders

Non-selected tokens simply vanish from the highlighted document. That removes $placeholder, the collision handling, and the class of bugs #4107 found (the placeholder being markdown emphasis / a JS constant / split by false). Three expectations change, all the same case: a template expression that is an entire unquoted attribute value or tag name (<span data-has-name={{x}}>, <h{$level}>) is no longer parsed as a tag by markup. Quoted values (href="{{url}}") are unaffected.

The helper

src/util/token-stream.js (exported from shared.js) has the two primitives this is built on, for everything else in the repo that merges one tokenized structure with another:

  • splitTokenStream(stream, offsets) — split at text offsets, cloning tokens that straddle a boundary.
  • insertTokens(stream, [[offset, token], …]) — insert tokens by position, nesting into spanning tokens.

Consumers to migrate are listed in the follow-up issue (keep-markup's DOM Range juggling, command-line's line stashing on the HTML string, shell-session, the /// / * doc-comment prefixes in xml-doc/javadoclike/jsdoc, markdown blockquotes, the per-string tokenizeStrings plugins, jsx/xquery's plain-text flattening).

Removed

templating() (replaced by src/core/tokenize/embed.js), $placeholder, the inner grammar option (nothing needs it: php's <? check now just re-tokenizes with withoutTokenize(grammar)).

Full suite green, lint clean, tsc clean (with --moduleResolution bundler, since the checked-in config is broken by TS 6).

🤖 Generated with Claude Code

https://claude.ai/code/session_01RbhmLXUybTbxCBSwGa2YZr


Generated by Claude Code

Meta-languages now declare *which parts* of their token tree are code in
the inner language, instead of a custom tokenizer or placeholder tricks:

    $inner: { select: ['unchanged, deleted, diff', 'unchanged, inserted, diff'] }

A selector names containers (token names, matched by type or alias, and
`:text` for the unmatched text). The strings directly inside the selected
containers are concatenated, highlighted as one whole with the inner
language, and the tokens are put back. Several selectors are highlighted
separately, later ones winning where they overlap.

- `diff` uses two selectors, the versions before and after the change, so
  multi-line constructs are highlighted correctly across changed and
  unchanged lines. Its grammar is back to a plain declaration.
- The default selector is `:text`, which is what templating languages
  need; nothing changes for them. Placeholders are gone: non-selected
  tokens simply vanish from the highlighted document, and inner tokens
  that span them stay whole with the tokens inserted by position.
- `src/util/token-stream.js` exports the two primitives this is built on,
  `splitTokenStream()` and `insertTokens()`, for reuse by everything else
  that merges one tokenized structure with another (keep-markup,
  command-line, doc-comment prefixes, …).
- `$placeholder` and the `inner` grammar option are removed; `templating()`
  is replaced by `src/core/tokenize/embed.js`.

Three expectations change where a template expression was an entire
unquoted attribute value or tag name (handlebars, latte, soy): markup no
longer sees a placeholder there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbhmLXUybTbxCBSwGa2YZr
@netlify

netlify Bot commented Sep 15, 2026

Copy link
Copy Markdown

Deploy Preview for dev-prismjs-com ready!

Name Link
🔨 Latest commit 7e18205
🔍 Latest deploy log https://app.netlify.com/projects/dev-prismjs-com/deploys/6ab2415b51cff3000860eb85
😎 Deploy Preview https://deploy-preview-4110--dev-prismjs-com.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@DmitrySharabin
DmitrySharabin added this pull request to stack #4113 September 16, 2026 07:33
@DmitrySharabin
DmitrySharabin removed this pull request from stack #4113 September 17, 2026 08:01
DmitrySharabin added a commit that referenced this pull request Sep 17, 2026
`isInnerSpec` keyed on `select` alone, so `$inner: { language: X }` — the
long form that `types.d.ts` documents a bare grammar reference as shorthand
for — was read as an inline grammar and crashed in `matchPattern`. Decide on
the key set instead, keeping the `select` type check so a one-token grammar
named `select` still works inline.

A selector naming no container (`''`, `' , '`, `[]`) silently disabled inner
highlighting. Throw instead, above the `!inner` early exit, so the same
malformed grammar fails the same way with or without an inner language.

Follow-up to #4110, whose code both bugs are in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@DmitrySharabin
DmitrySharabin added this pull request to stack #4115 September 17, 2026 08:02
@DmitrySharabin

Copy link
Copy Markdown
Member

Two corrections to the description, found while reviewing this PR together with #4112 and #4114. Both are about the text, not the code.


Three expectations change, all the same case: a template expression that is an entire unquoted attribute value or tag name (<span data-has-name={{x}}>, <h{$level}>) is no longer parsed as a tag by markup.

There is a third position, and two of the three changed fixtures in this PR are examples of it: an unquoted attribute name.

// django
'<div {{ x }}>a</div>'

v2   [tag [punctuation <][tag div] [attr-name [django ]][punctuation >]]
new  [tag [punctuation <][tag div] [django ][punctuation >]]

tests/languages/latte/html_feature.test and the second hunk of tests/languages/handlebars/handlebars_in_html_feature.test both move a template token out of attr-name, not out of attr-value.

The three positions differ in severity, which the one-line summary flattens:

position example effect
unquoted attribute value <span data-x={{x}}> the whole opening tag stops being a tag
tag name <h{$level}> tag survives, name is just h
unquoted attribute name <div {{on "click"}}> tag survives, loses the attr-name wrapper

Only the first loses a whole construct. Quoted values are unaffected, as the description says.

Measured across every markup-templating language — django, twig, handlebars, liquid, ejs, erb, etlua, tt2, smarty, ftl, latte, soy, php — all 13 change in all three positions. Filed as #4119 so it is tracked somewhere other than this description; it is recorded there as an accepted trade against the #4107 placeholder bugs, not as an argument to revert.


Full suite green, lint clean, tsc clean (with --moduleResolution bundler, since the checked-in config is broken by TS 6).

This does not reproduce. The repo pins TypeScript 5.8.3 (npx tsc --version), tsconfig.json:30 sets "moduleResolution": "node", and bare npx tsc exits clean both at a5e7eca and at the top of the stack — no flag needed, and npx tsc -p tests/tsconfig.json is clean too. Whatever produced the TS 6 error was probably a globally installed tsc rather than the repo's own. Worth dropping, since as written it sends a reader looking for a broken config.

@DmitrySharabin
DmitrySharabin removed this pull request from stack #4115 September 22, 2026 08:35
DmitrySharabin added a commit that referenced this pull request Sep 22, 2026
`isInnerSpec` keyed on `select` alone, so `$inner: { language: X }` — the
long form that `types.d.ts` documents a bare grammar reference as shorthand
for — was read as an inline grammar and crashed in `matchPattern`. Decide on
the key set instead, keeping the `select` type check so a one-token grammar
named `select` still works inline.

A selector naming no container (`''`, `' , '`, `[]`) silently disabled inner
highlighting. Throw instead, above the `!inner` early exit, so the same
malformed grammar fails the same way with or without an inner language.

Follow-up to #4110, whose code both bugs are in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DmitrySharabin added a commit that referenced this pull request Sep 22, 2026
`container.splice(start, n, ...segment)` in `embed` and `stream.splice(i, 1,
...parts)` in `insertTokens` pass every item as a call argument, which overflows
the stack once a document has more inner tokens than the engine takes arguments
— about 460 KB of markup through any templating language, where `v2` was fine.
Both now go through `replaceRange()`, which truncates and pushes instead.

A root-level `ignore` token is dissolved into the `:text` run, but the walk then
took it again, so a selector naming both `:text` and that token fed its text to
the inner language twice, and the second copy changed how the first one parsed.

Also in `token-stream.js`: the two halves of a split token shared the original's
alias array, so `addAlias` on one reached the other and the original;
`insertTokens` silently sliced text away when offsets were not ascending, and
now throws; and `splitTokenStream` no longer claims an isolation it does not
give — segments share their unsplit tokens with the input.

Tests for each, plus the `diff` behaviours nothing pinned: two selectors rather
than one, the normal-diff `<`/`>` blocks, and the single-alias branch of
`tokenMatches`.

Follow-up to #4110 and #4112, whose code these are in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@DmitrySharabin
DmitrySharabin added this pull request to stack #4124 September 22, 2026 08:48
@DmitrySharabin
DmitrySharabin removed this pull request from stack #4124 September 22, 2026 08:49
* Split token streams in one pass

`splitTokenStream` copied the remainder of the stream for every offset
(`stream.slice(i)`, `[b, ...stream.slice(i + 1)]`), and `embed` passes one
offset per selected run, i.e. one per diff line. A 12k-line
`diff:javascript` took 3197ms; `--cpu-prof` put 80% of samples in
`splitAt`. Walk the stream once instead, carrying the straddling item into
the next segment.

`diff:javascript`, this branch vs its base: 3k lines 189ms -> 103ms,
12k lines 3197ms -> 307ms, 48k lines 1290ms, i.e. linear again.

Two details the one-pass form has to preserve, both easy to get wrong and
both covered by new tests:

- A zero-length item sitting exactly on an offset belongs to the segment
  that starts there, not the one that ends there. `splitAt` got this from
  checking `pos >= offset` before consuming the item; without the
  `offsets[next] === start` clause, `diff:markdown` drops the empty
  `code-block` token that markdown emits for an empty fenced block.
- With no offsets there is nothing to split. That is the common path —
  `:text` is the default selector, so every templating language takes it —
  so it must not walk the stream measuring items.

200k random streams split both ways against the previous implementation:
no divergences.

`splitTokenStream` and `insertTokens` are public API via `shared.js` and
had no direct tests; add them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Re-point the #2008 test at what it now guards

The test's note described a placeholder that no longer exists, so it could
no longer fail for the reason it was written. The input still covers the
embedding it was about: the CSS around a PHP block in a `style` attribute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
DmitrySharabin added a commit that referenced this pull request Sep 22, 2026
`isInnerSpec` keyed on `select` alone, so `$inner: { language: X }` — the
long form that `types.d.ts` documents a bare grammar reference as shorthand
for — was read as an inline grammar and crashed in `matchPattern`. Decide on
the key set instead, keeping the `select` type check so a one-token grammar
named `select` still works inline.

A selector naming no container (`''`, `' , '`, `[]`) silently disabled inner
highlighting. Throw instead, above the `!inner` early exit, so the same
malformed grammar fails the same way with or without an inner language.

Follow-up to #4110, whose code both bugs are in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@DmitrySharabin
DmitrySharabin added this pull request to stack #4126 September 22, 2026 09:06
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.

2 participants