fabrica

hanna/fabrica

feat(highlight): line-aware, diff-ready highlighting via inkjet

3483583 · hanna committed on 2026-07-25

Consume inkjet's raw highlight-event stream and fold tree-sitter captures
onto a fixed set of stable hl-* classes (one table, documented in
docs/theming.md), so themes never see grammar internals. Output is
line-addressable: a span crossing a newline is split and its class
reopened on the next line, so every emitted line is independently
balanced HTML — the invariant blob anchors and per-side diff rows need.

Language resolution follows §8.1 (attribute override > shebang > modeline
> exact filename > longest extension > plain), deferring name matching to
inkjet's token table with a small alias table on top.

Highlighting can never fail a render: oversized, binary, and minified
inputs and any grammar panic degrade to plain text. inkjet's grammars
link libstdc++, added to the flake buildInputs; .snap snapshots are kept
in the build source alongside .sql.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +194 −14UnifiedSplit
crates/highlight/src/classes.rs +6 −1
@@ -56,6 +56,8 @@ pub fn class_for(capture: &str) -> Option<&'static str> {
5656
5757 #[cfg(test)]
5858 mod tests {
59+ #![allow(clippy::unwrap_used)]
60+
5961 use super::*;
6062
6163 #[test]
@@ -82,7 +84,10 @@ mod tests {
8284 fn every_class_is_documented_in_the_list() {
8385 for capture in ["keyword", "function", "type", "string", "comment"] {
8486 let class = class_for(capture).unwrap();
85- assert!(HL_CLASSES.contains(&class), "{class} missing from HL_CLASSES");
87+ assert!(
88+ HL_CLASSES.contains(&class),
89+ "{class} missing from HL_CLASSES"
90+ );
8691 }
8792 }
8893 }
crates/highlight/src/lang.rs +16 −4
@@ -240,7 +240,10 @@ mod tests {
240240
241241 #[test]
242242 fn extension_resolves_rust() {
243- assert_eq!(name("src/main.rs", "fn main() {}", None).as_deref(), Some("rust"));
243+ assert_eq!(
244+ name("src/main.rs", "fn main() {}", None).as_deref(),
245+ Some("rust")
246+ );
244247 }
245248
246249 #[test]
@@ -286,8 +289,14 @@ mod tests {
286289
287290 #[test]
288291 fn exact_filenames() {
289- assert_eq!(name("Dockerfile", "FROM x\n", None).as_deref(), Some("dockerfile"));
290- assert_eq!(name("Cargo.lock", "[[package]]\n", None).as_deref(), Some("toml"));
292+ assert_eq!(
293+ name("Dockerfile", "FROM x\n", None).as_deref(),
294+ Some("dockerfile")
295+ );
296+ assert_eq!(
297+ name("Cargo.lock", "[[package]]\n", None).as_deref(),
298+ Some("toml")
299+ );
291300 assert_eq!(name("flake.lock", "{}\n", None).as_deref(), Some("json"));
292301 }
293302
@@ -308,6 +317,9 @@ mod tests {
308317
309318 #[test]
310319 fn alias_table_maps_terraform() {
311- assert_eq!(name("main.tf", "resource {}\n", None).as_deref(), Some("hcl"));
320+ assert_eq!(
321+ name("main.tf", "resource {}\n", None).as_deref(),
322+ Some("hcl")
323+ );
312324 }
313325 }
crates/highlight/src/lib.rs +48 −5
@@ -74,7 +74,11 @@ pub struct Highlighted {
7474 /// resolved. Oversized, binary, minified, or unrecognized-language inputs — and any
7575 /// grammar failure — degrade to plain text; the call never fails.
7676 #[must_use]
77-pub fn highlight(path: &std::path::Path, content: &[u8], attr_override: Option<&str>) -> Highlighted {
77+pub fn highlight(
78+ path: &std::path::Path,
79+ content: &[u8],
80+ attr_override: Option<&str>,
81+) -> Highlighted {
7882 let lang = resolve(path, content, attr_override);
7983 if lang.is_plain() || is_unhighlightable(content) {
8084 return Highlighted {
@@ -90,7 +94,10 @@ pub fn highlight(path: &std::path::Path, content: &[u8], attr_override: Option<&
9094 language: None,
9195 };
9296 };
93- match lang.language().and_then(|inner| highlight_events(inner, source)) {
97+ match lang
98+ .language()
99+ .and_then(|inner| highlight_events(inner, source))
100+ {
94101 Some(lines) => Highlighted {
95102 lines,
96103 language: lang.name().map(str::to_string),
@@ -125,7 +132,9 @@ fn is_unhighlightable(content: &[u8]) -> bool {
125132 if window.contains(&0) {
126133 return true;
127134 }
128- content.split(|b| *b == b'\n').any(|line| line.len() > MINIFIED_LINE_BYTES)
135+ content
136+ .split(|b| *b == b'\n')
137+ .any(|line| line.len() > MINIFIED_LINE_BYTES)
129138 }
130139
131140 /// Run the grammar and fold its events into lines, or `None` on any grammar error
@@ -143,7 +152,10 @@ fn highlight_events(language: inkjet::Language, source: &str) -> Option<Vec<Line
143152 for event in events {
144153 match event.ok()? {
145154 HighlightEvent::HighlightStart(highlight) => {
146- let class = HIGHLIGHT_NAMES.get(highlight.0).copied().and_then(class_for);
155+ let class = HIGHLIGHT_NAMES
156+ .get(highlight.0)
157+ .copied()
158+ .and_then(class_for);
147159 stack.push(class);
148160 }
149161 HighlightEvent::HighlightEnd => {
@@ -179,7 +191,12 @@ fn plain_lines(source: &str) -> Vec<Line> {
179191 /// newlines. Each embedded `\n` finishes the current line — preserving blank lines
180192 /// — and the class carries onto the next line, which is the crux of producing
181193 /// balanced per-line markup for a span that crosses a break.
182-fn push_text(lines: &mut Vec<Line>, current: &mut Vec<Span>, text: &str, class: Option<&'static str>) {
194+fn push_text(
195+ lines: &mut Vec<Line>,
196+ current: &mut Vec<Span>,
197+ text: &str,
198+ class: Option<&'static str>,
199+) {
183200 for (i, segment) in text.split('\n').enumerate() {
184201 if i > 0 {
185202 lines.push(Line {
@@ -284,4 +301,30 @@ mod tests {
284301 assert_eq!(line_text(&lines[0]), "fn main() {}");
285302 assert_eq!(line_text(&lines[1]), "let x = 1;");
286303 }
304+
305+ /// Render lines to the same span markup the web layer emits, for snapshotting.
306+ fn render(lines: &[Line]) -> String {
307+ use std::fmt::Write as _;
308+
309+ let mut out = String::new();
310+ for line in lines {
311+ for span in &line.spans {
312+ match span.class {
313+ Some(class) => {
314+ let _ = write!(out, "<span class=\"{class}\">{}</span>", span.text);
315+ }
316+ None => out.push_str(&span.text),
317+ }
318+ }
319+ out.push('\n');
320+ }
321+ out
322+ }
323+
324+ #[test]
325+ fn snapshot_rust_fixture() {
326+ let src = "use std::io;\n\nfn add(a: i32, b: i32) -> i32 {\n a + b // sum\n}\n";
327+ let out = highlight(Path::new("fixture.rs"), src.as_bytes(), None);
328+ insta::assert_snapshot!(render(&out.lines));
329+ }
287330 }
crates/highlight/src/snapshots/highlight__tests__snapshot_rust_fixture.snap +9 −0
@@ -0,0 +1,9 @@
1+---
2+source: crates/highlight/src/lib.rs
3+expression: render(&out.lines)
4+---
5+<span class="hl-keyword">use</span> std<span class="hl-punctuation">::</span>io<span class="hl-punctuation">;</span>
6+
7+<span class="hl-keyword">fn</span> <span class="hl-function">add</span><span class="hl-punctuation">(</span><span class="hl-variable">a</span><span class="hl-punctuation">:</span> <span class="hl-type">i32</span><span class="hl-punctuation">,</span> <span class="hl-variable">b</span><span class="hl-punctuation">:</span> <span class="hl-type">i32</span><span class="hl-punctuation">)</span> -> <span class="hl-type">i32</span> <span class="hl-punctuation">{</span>
8+ a + b <span class="hl-comment">// sum</span>
9+<span class="hl-punctuation">}</span>
docs/decisions.md +56 −0
@@ -272,3 +272,59 @@ build-dep and a second copy of libgit2 in the graph.
272272 build sandbox's rpath, so the loader cannot find `libgit2.so.1.9` and the binary
273273 exits 127. The Nix build's own checks (`nix flake check`) embed rpath and are
274274 unaffected; this only fixes the interactive `just check` path.
275+
276+## 2026-07-24 — `.gitattributes` resolved by an own resolver, cached off `Repo`
277+
278+**Decision:** `crates/git` resolves attributes itself (`attributes.rs`): it walks a
279+tree collecting every `.gitattributes` blob with its directory prefix, parses each
280+into ordered rules, and matches paths with `globset`. Precedence is "deeper
281+directory wins, later line within a file wins", implemented by applying
282+shallowest-first into a map. The `moka` cache (`AttributesCache`) keyed by tree oid
283+is a **separate** type the web layer holds, not a field on `Repo`.
284+
285+**Alternatives:** Use libgit2's `Repository::get_attr`; cache inside `Repo`.
286+
287+**Rationale:** libgit2's attribute lookup is oriented at a working tree and is
288+unreliable against a bare repo and for per-ref accuracy (§3.5). `Repo` is opened
289+per operation (per the phase-5 threading decision), so a cache on it would never
290+outlive a request; placing the `moka` cache in a standalone `AttributesCache`
291+lets the long-lived web state share resolved sets across requests without coupling
292+to the handle's lifetime.
293+
294+## 2026-07-24 — Highlighting via inkjet (all grammars, default features)
295+
296+**Decision:** `crates/highlight` uses `inkjet` 0.11.1 with default features (which
297+bundle every tree-sitter grammar plus the `constants` capture-name table). We use
298+its `highlight_raw` event stream and build per-line HTML ourselves rather than its
299+built-in HTML formatter.
300+
301+**Alternatives:** Depend on `tree-sitter-highlight` directly with a hand-curated
302+grammar set; enable only `language-*` features for a smaller build.
303+
304+**Rationale:** inkjet is MIT/Apache-2.0 and vendors its grammars' C source *inside
305+the single crate*, so they never appear as separate nodes in cargo-deny's graph and
306+the license gate stays green. Bundling all grammars honours "highlighting
307+everywhere code appears" without a curation burden; the build cost is paid once and
308+cached. The built-in formatter emits one blob, which cannot satisfy the
309+line-addressable/per-side-diff requirement (§8), so we consume the raw event stream
310+and fold captures onto our stable `hl-*` classes in one table. inkjet's grammars
311+link `libstdc++`, added to the flake `buildInputs` so the built binary rpaths it and
312+the dev shell's `LD_LIBRARY_PATH` picks it up.
313+
314+## 2026-07-24 — Highlight guards; per-file timeout deferred
315+
316+**Decision:** Highlighting can never fail a render: oversized (> `max_highlight_bytes`),
317+binary (NUL in the first 8 KiB), and minified (any line > 5000 bytes) inputs skip
318+straight to plain text, and any grammar panic is caught (`catch_unwind`) and
319+degraded the same way. The spec's per-file **timeout** guard is **not** yet
320+implemented.
321+
322+**Alternatives:** Implement the timeout now via a watchdog thread and a
323+tree-sitter cancellation flag.
324+
325+**Rationale:** The size, binary, minified, and panic guards cover every failure
326+mode observed in practice; a hung grammar is vanishingly rare and, when it happens,
327+is bounded by the `spawn_blocking` pool at the call site rather than by silently
328+corrupting a page. Wiring a hard per-file deadline (a cancellation flag polled by
329+the grammar, or a watchdog thread) is deferred to the polish phase, where the
330+highlight cache and measured hot paths land together. Tracked as a known gap.
docs/theming.md +55 −0
@@ -0,0 +1,55 @@
1+# Theming
2+
3+fabrica re-brands and re-themes with **no rebuild**: structure lives in
4+`assets/base.css` (custom properties only, no literal colours), and a theme is a
5+single CSS file that defines those properties. This document catalogues the tokens
6+a theme sets. The layout-token families (`--fb-bg`, `--fb-accent`, …) are wired up
7+in the web phase; the **syntax-highlight** family below is live as of the highlight
8+phase and is documented here first because it is generated by `crates/highlight`.
9+
10+## Syntax highlight classes
11+
12+`crates/highlight` emits a fixed, stable set of CSS classes — never inline colours
13+and never tree-sitter capture names. Every grammar's captures are folded onto this
14+set through one table (`crates/highlight/src/classes.rs`), so a theme styles code by
15+targeting these twelve classes and nothing else:
16+
17+| Class | Token | Typical captures folded in |
18+| --- | --- | --- |
19+| `hl-keyword` | `--fb-hl-keyword` | `keyword`, `keyword.control.*`, `label` |
20+| `hl-function` | `--fb-hl-function` | `function`, `function.macro`, `method` |
21+| `hl-type` | `--fb-hl-type` | `type`, `type.builtin`, `constructor`, `namespace` |
22+| `hl-string` | `--fb-hl-string` | `string`, `escape`, `char`, `regex` |
23+| `hl-number` | `--fb-hl-number` | `constant.numeric.*`, `number`, `float`, `boolean` |
24+| `hl-comment` | `--fb-hl-comment` | `comment` |
25+| `hl-constant` | `--fb-hl-constant` | `constant`, `constant.builtin` |
26+| `hl-variable` | `--fb-hl-variable` | `variable`, `property`, `parameter`, `field` |
27+| `hl-operator` | `--fb-hl-operator` | `operator` |
28+| `hl-punctuation` | `--fb-hl-punctuation` | `punctuation.*` |
29+| `hl-attribute` | `--fb-hl-attribute` | `attribute`, `annotation`, `decorator` |
30+| `hl-tag` | `--fb-hl-tag` | `tag` |
31+
32+A theme maps each class to a colour via its `--fb-hl-*` token, e.g.
33+
34+```css
35+:root {
36+ --fb-hl-keyword: #c678dd;
37+ --fb-hl-string: #98c379;
38+ /* … */
39+}
40+.hl-keyword { color: var(--fb-hl-keyword); }
41+```
42+
43+Captures with no entry in the table render as ordinary text inside their line, so an
44+unstyled grammar never produces stray markup. Highlighting is line-addressable:
45+every emitted line is independently balanced HTML, so blob-view anchors (`#L12`) and
46+per-side diff rows can slice by line number without breaking spans.
47+
48+## Layout tokens
49+
50+The full `--fb-*` layout-token catalogue (`--fb-bg`, `--fb-bg-raised`,
51+`--fb-border`, `--fb-fg`, `--fb-accent`, `--fb-success`, `--fb-warning`,
52+`--fb-danger`, `--fb-diff-add-bg`, `--fb-diff-del-bg`, …) is documented alongside
53+the web shell, where `base.css` and the embedded `dark`/`light` themes are
54+introduced. A theme that sets only the documented tokens must yield a complete,
55+coherent UI — that is the acceptance test for themeability.
flake.nix +4 −4
@@ -34,15 +34,15 @@
3434
3535 craneLib = (inputs.crane.mkLib pkgs).overrideToolchain fenixToolchain;
3636
37- # Keep `.sql` migration files in the build source: the `store` crate
38- # embeds them via `sqlx::migrate!` (and reads them in tests), and
39- # crane's default cargo-source filter would strip them.
37+ # Keep `.sql` migration files (embedded by `store` via `sqlx::migrate!`)
38+ # and `.snap` insta snapshots (read by `highlight`'s snapshot tests) in
39+ # the build source: crane's default cargo-source filter would strip both.
4040 src = pkgs.lib.cleanSourceWith {
4141 src = ./.;
4242 name = "source";
4343 filter =
4444 path: type:
45- (builtins.match ".*\\.sql$" path != null) || (craneLib.filterCargoSources path type);
45+ (builtins.match ".*\\.(sql|snap)$" path != null) || (craneLib.filterCargoSources path type);
4646 };
4747
4848 commonArgs = {