sparkles:syntax — next-milestones handoff
A pick-up guide for the six items after M7 (injections). M0–M7 are on main / PR #104. Each section is self-contained: goal, approach grounded in the existing code and the reference (tree-sitter-highlight 0.27 at /home/petar/code/repos/parsing/tree-sitter/crates/highlight/src/highlight.rs), the seams already in place, the traps, and how to verify. File:line references are anchors, not gospel — confirm before editing.
Suggested order & dependencies
4 toRgb + byStyledLine ──► 6 raylib viewer (viewer needs both helpers)
3 theme-file parsing (independent, small)
5 CSS-variable HTML (independent, renderer)
1 injection.combined (engine, small — completes injections)
2 locals (engine, largest — hardest of the six)Recommended sequence: 4 → 6 (tiny helpers, then the end-to-end demo that proves them), then 3 and 5 (independent renderer/theme wins), then 1 (closes the injection story), then 2 (the big one). 1 and 2 are pure engine work and don't touch 3/4/5/6.
Shared context (applies to all engine/consumer work)
- Grammars come from
$SPARKLES_TS_GRAMMAR_PATH(set by the devshell,nix/shells/default.nix:180). Run insidenix develop; grammar-dependent testsskipTestwhen it is unset. - One
LabelSet.standard()instance must feed bothTsConfigCache.createandresolveTheme, or label ids won't align (ResolvedTheme.opIndexis total but returnsdefaultsfor out-of-vocab ids —theme.d:82). - Totality law: every failure degrades to plain text; only size-guard trips and failed root parses return an error (
highlighter.d:82). @safe pure nothrow @nogcwhere the design allows; thets/engine is@system(grammar dlopen, raw nodes). Tests carry explicit safety attributes.
1. injection.combined
Goal. Honor #set! injection.combined: gather the content nodes of all matches of a combined injection pattern into one child layer parsed over their disjoint ranges, instead of one layer per match. Completes the injection feature (M7 shipped everything else).
Why it matters. Grammars use combined injections when many small fragments share one embedded parse (e.g. all the string chunks of a heredoc, or every comment line of a doc block, parsed as a single document). Without it, each fragment becomes an isolated layer and cross-fragment structure is lost.
Reference (highlight.rs). Construction builds a second, injection-only query (lines 387–404); combined patterns are disabled in the main query, non-combined patterns disabled in the combined query. During layer build (617–651) the combined query runs over the whole tree and accumulates injections_by_pattern_index: (lang, Vec<content_node>, include_children) — overwriting language, pushing each content node — then builds one layer per pattern from intersect_ranges(&ranges, &content_nodes, include_children).
Approach in our engine. We already keep injections as a separate query and discover up front (buildLayers, highlighter.d), so this is simpler than the reference's two-query split:
- In
buildLayers's injection loop, split matches into non-combined (current behavior: one child layer each) and combined (accumulate). - A pattern is combined iff its
#set!settings containinjection.combined(already parsed intoconfig.injectionPredicates[patternIndex].settings—injection.d:injectionForMatchreads settings the same way for.language). Add anisCombinedread there (or abool[]on the config). - Accumulate combined matches into
size_t[pattern → (lang, TSRange[])]: for each combined match, resolve language +intersectRangesand append its ranges. After the match loop, enqueue oneWork(childConfig, mergedRanges, depth+1)per combined pattern.
Key files. libs/syntax/src/sparkles/syntax/ts/injection.d (an isCombined / combined-settings read), .../ts/highlighter.d (buildLayers accumulation).
Reused seams. #set! already parsed (predicates.d:206); intersectRanges already returns a TSRange[] you can concatenate (injection.d); setIncludedRanges already takes multiple disjoint ranges (wrappers.d).
Traps. (a) Combined ranges must stay ascending and non-overlapping — sort the accumulated ranges by start_byte before enqueue (tree-sitter rejects unordered ranges; setIncludedRanges returns false). (b) A combined pattern with a captured @injection.language (rather than a #set!) can name different languages across matches — the reference lets the last win; match that or bucket by (pattern, language).
Tests. Find or synthesize a grammar whose injections.scm sets injection.combined (grep the bundle). If none in the bundle, a hand-written config + query fixture over a small language (like the existing jsonConfigForTest(queryOverride) pattern) exercising two content nodes → one layer. Assert assertWellFormed + that a construct spanning both fragments highlights coherently.
Effort: small/medium.
2. Locals (@local.scope / .definition / .reference)
Goal. Def/use variable coloring: a @local.reference borrows the highlight of its resolved @local.definition; (#is-not? local) highlights are suppressed on nodes that resolved as locals. The spec frames this as "a quality bonus, deferred indefinitely for batch rendering" — it is the largest and trickiest of the six.
Reference (highlight.rs), precise. Locals is woven into the merged query and the per-node capture stream:
- Merged query, locals-first (353–454).
injection ++ locals ++ highlightsinto one query;locals_pattern_index/highlights_pattern_indexpartition pattern-index space into three bands. Dispatch:pattern_index < locals_pattern_index⇒ injection;< highlights_pattern_index⇒ locals; else highlight. Load-bearing guarantee (1107–1111): captures for one node arrive in pattern-index order, so a node's locals captures precede its highlight capture in the single stream. - Capture indices (417–435):
local.scope,local.definition,local.definition-value,local.reference. non_local_variable_patterns(408–415): per-pattern(#is-not? local).- Per-layer state (182–191):
scope_stack: Vec<LocalScope{inherits, range, local_defs: Vec<LocalDef{name, value_range, highlight}>}>, root{inherits:false, 0..MAX, []}. - The locals block (1005–1146): (1) pop scopes while
capture.start > top.range.end; (2) while the same-node capture is a locals pattern —local.scopepushes a scope (local.scope-inherits#set!, default true);local.definitionpushes a def, reads its sibling@local.definition-valuerange, and remembers the def's highlight slot;local.reference(only if no definition on this node) walks scopes outward for a def with matching name andvalue_range.end <= ref.start, honoringinherits; (3) in the highlight same-node last-wins, skip a following highlight capture when the node resolved local andnon_local_variable_patterns[pattern]; (4) write the def's own highlight back into its slot; emitreference_highlight.or(current). - Locals emit no events — they only mutate
scope_stackand redirect/suppress the node's own highlight capture.
Approach in our engine — the decisions.
- Merge
localsScminto the highlights query (locals first). Injections stay a separate query (they only spawn layers — no per-node highlight coupling), but locals must share the stream with highlights so a node's locals captures precede its highlight capture. InTsHighlightConfig.create, whenlocalsScmis non-empty build the main query fromlocalsScm ~ "\n" ~ highlightsScm. GetlocalsPatternIndexfor free by compilinglocalsScmalone first and taking itspatternCount— avoids a newts_query_start_byte_for_patternC binding. (localsScmis currently discarded atconfig.d:60.) - New config fields:
uint localsPatternIndex,localScopeIndex,localDefIndex,localDefValueIndex,localRefIndex(defaultuint.max, scanned in the samecaptureName(i)loop that finds injection indices),hasLocals().config.predicatesis already rebuilt overquery.patternCountso it covers the merged query — no reindex bug as long as the same merged query is executed and looked up (it is). - Per-layer scope stack + capture-struct fields. Add
patternIndexand (for defs)valueStart/valueEndtoPendingCapture/LayerCapture— because our engine copies one capture out and drops the match (highlighter.d:136comment), the@local.definition-valuesibling capture must be read duringpeek/peekLayerwhile the match is live and stashed. Add aLocalScope[]scope stack (per-LayerininterleaveLayers; a local inhighlightTree). - Wire the loop in both loops (
highlightTree~204–231 andinterleaveLayers~511–540): scope-pop → locals accumulation → suppression in last-wins → write-back →emit reference_highlight ? reference : own.
Key files. config.d (merge + fields), highlighter.d (scope stack + locals block in both loops + capture-struct fields), predicates.d (nothing to add — isNotLocal at predicates.d:73,218 and #set! settings already exist), maybe wrappers.d (only if you take the start_byte_for_pattern route instead of the throwaway-count route).
Hardest parts (flag prominently).
- Merged-query ordering is non-negotiable — a separate locals cursor breaks the redirect/suppress mechanism or forces an expensive cross-stream node join.
- Two divergent loops —
highlightTreeandinterleaveLayerseach need the locals block; consider refactoring their per-capture core into one shared helper first. TheinjectionAgreesWithSingleLayerguard (highlighter.d:830) asserts they stay in agreement — locals must preserve that. definition-valuevs eager copy-out — read the sibling capture inpeek.#is-not? localis parsed but never consumed — B3.3 is its first user.
Tests. A language with a real locals.scm in the bundle (many ship one — check queries/locals.scm per grammar). Fixtures: a let x = 1; use(x) → the use reference gets x's definition color; a shadowing case across scopes; a let x = x self-reference guard; a (#is-not? local) suppression case. Reuse labeledSpans + assertWellFormed. Add a no-locals agreement check (a config without localsScm yields the identical stream to today).
Effort: medium/large. Budget a full session; land the config-merge + fields first (green, no behavior change), then the loop logic.
3. Theme-file parsing
Goal. Load themes from a file into the existing Theme model (plain data: Theme{name, defaultFg, defaultBg, ThemeRule[] rules}) at runtime — today the 30+ themes in themes.d are build-time codegen (tools/download_themes.d), with no runtime parser.
What exists (reuse). tools/download_themes.d is a codegen tool that fetches Shiki/TextMate theme JSON and emits D source. Its scope→selector mapping is the crux and is directly liftable:
scopeMappingRules(download_themes.d:60) — ordered TextMate-scope → dotted-label table (entity.name.function → function,keyword.operator → operator, …).mapScopeToLabel(scope)(download_themes.d:143) — identity fast-path for scopes already instandardLabels, then the prefix table, then longest-dotted- prefix fallback. Returns a real label string — reusable as-is.cleanHexColor/parseFontStyle(:170,:189) — logic reusable, but they return D-source fragments; re-express to returnColor/FontStyle.color.d:parseHexColor(color.d:108) already parses#RGB/#RRGGBB/#RRGGBBAAintoColor— use it for color literals.
Parser availability (decides the format).
- TOML: none — no parser anywhere in the repo/registry/nix. Helix-TOML would need a new dub dependency on
libs/syntax, which today depends only onexpected+ internal packages. Weigh that. - JSON:
std.json(Phobos), no new dep.
Recommendation. Ship JSON first (zero new deps):
- A native
ThemeJSON round-trip (serialize/parse the library's own model) — trivial, for user-authored themes. - TextMate/VSCode JSON — lift
scopeMappingRules+mapScopeToLabelfrom the tool into a runtimets/../theme_file.d(ortheme_parse.d), reusingparseHexColor. This is the format the project already handles correctly.
Defer Helix TOML (best selector fidelity — Helix scopes ≈ our vocabulary per label.d's provenance note) until the team accepts a TOML dep; it also needs [palette] named-color resolution and modifiers-array → FontStyle.
Key files. New libs/syntax/src/sparkles/syntax/theme_file.d; reference tools/download_themes.d (mapping), theme.d (target model + resolveTheme), color.d (parseHexColor), label.d (vocabulary).
Traps. TextMate scope may be a string or array, each comma-separated (handle both — download_themes.d:302); many scopes map to null (drop them); theme settings entry with no scope is the document default (defaultFg/Bg). De-dup rules (download_themes.d uses a seen set).
IMPORTANT
Read label-vocabulary dialects before lifting mapScopeToLabel. "Many scopes map to null (drop them)" is only half right: some of that dropping was a defect, since labels the shipped grammars emit had no route from TextMate scope space at all. The table has since gained those routes, and the tool gained excessBudget (specificity ranking, so a narrow scope cannot repaint a broad label) and mergeByLabel (per-property merging, reproducing the cascade a ThemeRule cannot express). Lift all three, not just the table — and note that mergeByLabel means one emitted rule per label, so the parser should merge rather than append.
sparkles.syntax.ts.coverage audits the result against the grammar bundle and fails dub test :syntax on a regression; point it at a parsed theme too.
Tests. Round-trip a known Theme through native JSON (==); parse a small TextMate JSON fixture and assert specific ThemeRules resolve (resolveTheme(parsed, LabelSet.standard())[find("keyword")] matches expected). Golden a full parse against one of the codegen'd themes.d entries.
Effort: small/medium (native JSON small; TextMate JSON medium via the mapping lift).
4. toRgb(Color, fallback) + byStyledLine
Goal. The two small, recorded GPU-consumer helpers that a non-terminal backend (item 6) needs. Both are documented seams.
toRgb — concretize a Color to RgbColor. Color is a sum type (color.d:50, Kind.{unset, default_, palette, rgb}); a GPU/raylib consumer needs concrete RgbColor{r,g,b}:
RgbColor toRgb(Color c, RgbColor fallback) @safe pure nothrow @nogc
{
final switch (c.kind)
{
case Color.Kind.rgb: return c.rgb;
case Color.Kind.palette: return xterm256ToRgb(c.index); // color.d:300
case Color.Kind.unset:
case Color.Kind.default_: return fallback; // theme default fg/bg
}
}The building blocks (xterm256ToRgb, RgbColor) already exist; this is the recorded seam at color.d:13,294. Home: color.d.
byStyledLine — split styled runs on newlines. byStyledSpan (event.d:149) yields StyledSpan runs that can contain '\n'; a (col,row) consumer needs runs clipped to single lines. Add a lazy adapter over byStyledSpan that emits, per line, the run segments plus a line-break marker (or a (line, StyledSpan) pair). The ANSI renderer already scans each run for '\n' and resets per line (render/ansi.d:98) — extract that logic as the reusable adapter. Home: event.d (next to byStyledSpan). Keep it engine-agnostic (offsets only; the caller slices source).
Key files. color.d (toRgb + test), event.d (byStyledLine + test).
Traps. toRgb — default_ and unset both fall back (a theme "default" color means "use the document default", not black). byStyledLine — a run ending exactly at '\n', an empty line (consecutive '\n'), and the final line without a trailing newline; keep it @nogc/lazy (no allocation).
Tests. toRgb: each Kind incl. palette→known RGB and default→fallback. byStyledLine: a multi-line run splits into per-line segments with correct offsets; empty lines; CRLF is out of scope (byte offsets only).
Effort: small (both). Do these before item 6.
5. CSS-variable multi-theme HTML
Goal. A third HtmlMode (the Shiki doctrine): emit one HTML document that renders under multiple themes by swapping CSS custom properties — no re-highlighting, no duplicate markup. render/html.d already reserves the cssVariables slot (doc note; HtmlMode today is {inlineStyles, cssClasses}).
Approach. Two viable shapes; pick one (or support both):
- Class + variables (recommended): reuse
cssClassesmarkup (<span class="syn-keyword">), but the stylesheet defines each label's color as a CSS variable per theme, switched by a:root[data-theme="dark"]/@media (prefers-color-scheme: dark)selector. ExtendwriteThemeStylesheetto take N resolved themes and emit.syn-keyword{color:var(--syn-keyword)}+:root{--syn-keyword:#…}blocks per theme. Markup is theme-independent; the page togglesdata-theme. - Inline dual-color (Shiki's default): each
<span>carries both colors as vars:style="color:var(--s0);--s0:#dark;--s1:#light"with a root rule choosing--s0/--s1. Self-contained per token, larger output.
Go with class + variables — it composes with the existing cssClasses path and the theme-aware-page pattern (the repo's Artifacts already use :root[data-theme=…] + prefers-color-scheme).
Key files. render/html.d: add HtmlMode.cssVariables; generalize writeThemeStylesheet to writeThemeVariables(themes[], w); renderHtml's cssVariables case reuses the cssClasses tag path.
Reused seams. writeClassName (dots→dashes), writeStyleDeclarations, concreteRgb/writeHexRgb, xterm256ToRgb — all already in html.d. The per-line close/reopen invariant is unchanged.
Traps. A label unstyled in theme A but styled in theme B still needs a class
- a var in both (or the fallback shows through). Decide the variable naming (
--syn-<label>) and the theme-selection contract (data-attr vs media query vs both).default_/unsetcolors emit no declaration (as today).
Tests. Golden the stylesheet for two themes (dark+light) — assert both --syn-keyword blocks and one class rule; assert markup is byte-identical to cssClasses mode (only the stylesheet differs). Reuse the balanced-tags-per-line property test.
Effort: medium.
6. Raylib text-file viewer (apps/syntax-viewer)
Goal. A new app: open a source file, highlight it with sparkles:syntax, and render it with raylib — the end-to-end demo of the GPU/third-backend contract and the motivating consumer for item 4. Depends on item 4 (toRgb + byStyledLine); build those first (in the lib, or in the app then upstream them).
Reuse from apps/terminal (take the scaffolding, drop the VT engine). ~90% of app.d is terminal-specific (pty, ghostty, kitty graphics) — ignore it. Reuse:
- Window init (
app.d:377),SetTargetFPS(60), resizable flag. - Font:
getRequiredCodepoints(:265) +LoadFontEx(:390) atlas;fontHasGlyph+glyphCachefallback (:250);fc-matchname resolution (:359). - Cell metrics:
MeasureTextEx(font, "M", …)→cellWidth/cellHeight(:429). - Per-glyph draw + fake styling — bold=redraw+1px, italic=x-shift, underline/strike=thin
DrawRectangle(:859–903) — maps straight ontoFontStylebits. - Font-size zoom (
:694) + resize (:726) (clearglyphCache+ recompute cells on font reload).
Data path (the contract).
GrammarRegistry.fromEnvironment() // registry.d:25
auto cache = TsConfigCache.create(®istry, labels); // injection.d:190 labels = LabelSet.standard()
auto sink = appender!(HighlightEvent[]);
highlightInjected(cache, extensionOf(path), source, sink); // highlighter.d:293
auto theme = resolveTheme(builtinDark, labels); // theme.d:95 (SAME labels)
foreach (line, run; byStyledLine(sink[])) { // item 4
StyleSpec s = theme[run.label]; // theme.d:82
RgbColor fg = toRgb(s.fg, toRgb(theme.defaults.fg, docFg)); // item 4
// DrawTextEx(source[run.start..run.end]) at (col*cellW, line*cellH)
}Language from the file extension: pass the extension (no dot) straight to canonicalLanguage (registry.d:110) — it already folds rs/py/ts/md/… and TsConfigCache.resolve calls it internally. Unknown ext → miss → plain text.
Dependencies / wiring. apps/syntax-viewer/dub.sdl: dependency "raylib-d" version="~>6.0.1", libs "raylib", dependency "sparkles:syntax" path="../.." (+ sparkles:core-cli for args). Add subPackage "apps/syntax-viewer" to the root dub.sdl. raylib is already a devshell system package (nix/shells/default.nix:138); SPARKLES_TS_GRAMMAR_PATH is set there too.
Design decisions to make.
- Highlight once on load (batch, whole file), buffer the events; re-highlight only on file change.
- Scrolling: build a line-start byte-offset index; draw only
StyledSpans in the visible line range (this is wherebyStyledLineearns its keep). - Wide chars/tabs: advance columns via
graphemeClusterWidth(base/text/width.d:117) — CJK/emoji = 2 cells — not naive+1; expand tabs viewer-side. - Gutter (line numbers), theme picker (
builtinDark/builtinLight+ the ~20 named themes),ClearBackground(Theme.defaultBg). - Degrade to plain text when the grammar is missing (totality).
Traps. highlightInjected is @system; the whole app is fine as @system. Registry/cache must outlive the highlight calls (own them at app scope). Don't re-resolveTheme per frame. LoadFontEx only bakes the codepoints you pass.
Tests. Apps are hard to unit-test; add a headless smoke (highlight a fixture file, assert non-empty styled runs + well-formed) and a manual "open a .d/.md file, eyeball it" checklist. Consider a screenshot in the app README.
Effort: medium (mostly viewer plumbing — the highlighting is a solved path).
Cross-cutting reminders
- Commit per phase, each green (
dub test :syntax/:tree-sitter); keep theinjectionAgreesWithSingleLayerregression guard passing for any loop change. - New files must be
git added beforenix/flake builds see them; a new app needs the root-dub.sdlsubPackageline and (if a new dep)dub.selections.jsonnix/dub-lock.jsonupdates.
- Update
docs/specs/syntax/PLAN.md§4 and the engine reference (docs/libs/syntax/reference/engine.md) as each lands.