feat(editor): colour Markdown from the renderer, not from a regex - #686
Merged
Conversation
Monaco's Markdown grammar has a dozen token names: one `keyword` for a heading's `#` and its words alike, a task checkbox read as a link, and no idea that ==highlight==, ++insert++, $math$, wikilinks or footnotes exist — all of which this app renders. It also guesses the other way: `snake_case_word` matches its emphasis rule and renders as prose. So the editor now asks the renderer. `semantic.rs` walks the same comrak parse the preview is built from and reports ranges; a DocumentSemanticTokensProvider hands them to Monaco, which merges them over the grammar per attribute — anything the parse says nothing about keeps the colour it already had. Two details the parse forced. The raw buffer is parsed, not the preprocessed one: the wikilink and embed rewrites preserve line numbers but not columns, and columns are the point. And columns are converted to UTF-16 in Rust, because comrak counts bytes and Monaco counts code units — `# 标题` disagrees from the first character.
PathGao
force-pushed
the
feat/semantic-tokens
branch
from
August 19, 2026 16:11
05a444f to
3845fd8
Compare
`math_spans` sliced each span out of the line it started on, on the comment's claim that a math span never crosses one. `$$…$$` does: it opens on one line, closes on another, and `find_math_spans` returns the whole range. Slicing byte 55 out of a two-byte `$$` line panics, and the release profile sets `panic = "abort"`, so the process is gone — the window vanishes a moment after the editor first asks for tokens, which is when a document with display math is opened or edited. Ranges are cut at the line ends now, so the opening `$$`, each line of the formula and the closing `$$` are separate spans. Verified over every `.md` on this machine and every prefix of each, since truncation is how an unterminated construct shows up.
This was referenced Aug 19, 2026
Closed
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.
Stacked on #684 — that PR is the base branch, and it should merge first. This one only adds the second layer; the palette and its
.mdscoping come from there.What this is
The editor's colours now come from the same comrak parse the preview is rendered from, instead of from Monaco's Markdown grammar alone. The rule behind them becomes exact: if it is coloured, it renders.
What that fixes, measured by typing the line and reading the rendered tokens back:
- [x] task[x]purple — the grammar reads the brackets as a link[x]blue, as markup~~gone~~==lit==++new++[[a page]],$E=mc^2$snake_case_word\b_[^_]+_\bmatches inside the word**unclosed## Titlekeywordfor the whole line##as markup,Titleas bold contentMechanism
Monaco merges the two layers per attribute, not per range (
sparseTokensStore.js:136): a semantic token declares which of foreground/bold/italic/underline/strikethrough it owns, the grammar keeps the rest, and any range the parse says nothing about is left entirely alone. That is what makes this additive rather than a second source of truth — the grammar still paints instantly on every keystroke, and these spans refine it a tick later.Three things the parse forced, each a defect if missed:
The raw buffer is parsed, not the preprocessed one.
convert_markdownrewrites wikilinks, internal embeds and parenthesized autolinks before comrak sees the text, and those rewrites change a line's length ([[a]]→[a](a.md)). Line numbers survive — that is the contractdata-sourceposand the task-checkbox toggle rest on — but columns do not, and columns are the whole point. The cost is that comrak cannot see the app's own syntaxes in the raw text, so wikilinks are scanned separately and math reusesfind_math_spans, the same scannermask_math_spansuses to decide what KaTeX will typeset.Columns are converted to UTF-16 in Rust. comrak reports 1-based byte offsets; Monaco counts UTF-16 code units. For
中文 **粗体**the strong marker is at byte 7 and unit 3, and a span computed in bytes lands mid-character, where Monaco drops it silently. Emoji make it three counting systems.Semantic rules spell out their font styles.
standaloneThemeService.getTokenStyleMetadatareturns bold and italic as booleans rather than "not set", so a semantic token always claims those bits — a rule that stays silent about bold switches it off for its range. That is the opposite of the grammar rules, where silence means "leave it alone", and it is whySEMANTIC_TOKEN_ROLESnames afontStylewherever one matters.Also:
'semanticHighlighting.enabled': trueis set explicitly.StandaloneTheme.semanticHighlightingis hard-codedfalse, so'configuredByTheme'cannot reach it and the provider would never be asked.Scope
The grammar keeps its rules. Deleting them would leave text unpainted for the tick between a keystroke and the parse; keeping them costs nothing, because the semantic layer overrides where it has an opinion.
One parse is cached per model version, because Monaco asks on scroll as well as on edit. Measured on this machine, release build, median of 20: 0.67 ms for a 527-line document, 2.06 ms for 3337 lines, 24 ms for 1.7 MB — of which the AST walk is 1–4%; the rest is the parse the preview already pays for. If the 1.7 MB case ever matters,
registerDocumentRangeSemanticTokensProviderasks only for the visible range and is a drop-in.Not covered by the parse, so not coloured: nothing in a fenced code block (the embedded language grammar keeps it), and reference-style link definitions.
Tests
src-tauri/src/semantic.rs— 8 unit tests over the extractor: the heading split, UTF-16 columns for CJK and for an astral character, the task checkbox not being a link, the four syntaxes the grammar cannot see,snake_case_wordand**unclosedproducing nothing, fences marked without touching the code between them, and — the one that guards the encoding — spans never overlapping, since Monaco's delta encoding turns an overlap into a token on the wrong line.scripts/semanticTokens.test.ts— 6 tests over the three seams the compiler cannot see: every kindsemantic.rsemits is in the legend, every legend type has a theme rule and vice versa, the font styles are spelled out, the delta encoding, an unknown kind being dropped rather than mapped to whatever construct sits at that index, and the enable switch being present.Verification
The before/after table was measured in Chromium against the dev server with
window.__TAURI_INTERNALS__stubbed, feeding the provider the exact spans the real extractor prints for that text, then readinggetComputedStyleoff the rendered tokens:##and its title blue at weight 700,[x]blue rather than purple,~~删除~~muted and struck,==高亮==amber,snake_case_wordplain.What that does not cover: the IPC hop itself. In the browser the command is a stub — the extractor is verified by its own tests and by running it over fixtures, but nobody has yet watched Monaco ask Rust and repaint in a real window. That is what the desktop build is for.