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
57#[cfg(test)]57#[cfg(test)]
58mod tests {58mod tests {
59 #![allow(clippy::unwrap_used)]
60
59 use super::*;61 use super::*;
6062
61 #[test]63 #[test]
@@ -82,7 +84,10 @@ mod tests {
82 fn every_class_is_documented_in_the_list() {84 fn every_class_is_documented_in_the_list() {
83 for capture in ["keyword", "function", "type", "string", "comment"] {85 for capture in ["keyword", "function", "type", "string", "comment"] {
84 let class = class_for(capture).unwrap();86 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 );
86 }91 }
87 }92 }
88}93}
crates/highlight/src/lang.rs +16 −4
@@ -240,7 +240,10 @@ mod tests {
240240
241 #[test]241 #[test]
242 fn extension_resolves_rust() {242 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 );
244 }247 }
245248
246 #[test]249 #[test]
@@ -286,8 +289,14 @@ mod tests {
286289
287 #[test]290 #[test]
288 fn exact_filenames() {291 fn exact_filenames() {
289 assert_eq!(name("Dockerfile", "FROM x\n", None).as_deref(), Some("dockerfile"));292 assert_eq!(
290 assert_eq!(name("Cargo.lock", "[[package]]\n", None).as_deref(), Some("toml"));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 );
291 assert_eq!(name("flake.lock", "{}\n", None).as_deref(), Some("json"));300 assert_eq!(name("flake.lock", "{}\n", None).as_deref(), Some("json"));
292 }301 }
293302
@@ -308,6 +317,9 @@ mod tests {
308317
309 #[test]318 #[test]
310 fn alias_table_maps_terraform() {319 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 );
312 }324 }
313}325}
crates/highlight/src/lib.rs +48 −5
@@ -74,7 +74,11 @@ pub struct Highlighted {
74/// resolved. Oversized, binary, minified, or unrecognized-language inputs — and any74/// resolved. Oversized, binary, minified, or unrecognized-language inputs — and any
75/// grammar failure — degrade to plain text; the call never fails.75/// grammar failure — degrade to plain text; the call never fails.
76#[must_use]76#[must_use]
77pub fn highlight(path: &std::path::Path, content: &[u8], attr_override: Option<&str>) -> Highlighted {77pub fn highlight(
78 path: &std::path::Path,
79 content: &[u8],
80 attr_override: Option<&str>,
81) -> Highlighted {
78 let lang = resolve(path, content, attr_override);82 let lang = resolve(path, content, attr_override);
79 if lang.is_plain() || is_unhighlightable(content) {83 if lang.is_plain() || is_unhighlightable(content) {
80 return Highlighted {84 return Highlighted {
@@ -90,7 +94,10 @@ pub fn highlight(path: &std::path::Path, content: &[u8], attr_override: Option<&
90 language: None,94 language: None,
91 };95 };
92 };96 };
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 {
94 Some(lines) => Highlighted {101 Some(lines) => Highlighted {
95 lines,102 lines,
96 language: lang.name().map(str::to_string),103 language: lang.name().map(str::to_string),
@@ -125,7 +132,9 @@ fn is_unhighlightable(content: &[u8]) -> bool {
125 if window.contains(&0) {132 if window.contains(&0) {
126 return true;133 return true;
127 }134 }
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)
129}138}
130139
131/// Run the grammar and fold its events into lines, or `None` on any grammar error140/// 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
143 for event in events {152 for event in events {
144 match event.ok()? {153 match event.ok()? {
145 HighlightEvent::HighlightStart(highlight) => {154 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);
147 stack.push(class);159 stack.push(class);
148 }160 }
149 HighlightEvent::HighlightEnd => {161 HighlightEvent::HighlightEnd => {
@@ -179,7 +191,12 @@ fn plain_lines(source: &str) -> Vec<Line> {
179/// newlines. Each embedded `\n` finishes the current line — preserving blank lines191/// newlines. Each embedded `\n` finishes the current line — preserving blank lines
180/// — and the class carries onto the next line, which is the crux of producing192/// — and the class carries onto the next line, which is the crux of producing
181/// balanced per-line markup for a span that crosses a break.193/// balanced per-line markup for a span that crosses a break.
182fn push_text(lines: &mut Vec<Line>, current: &mut Vec<Span>, text: &str, class: Option<&'static str>) {194fn push_text(
195 lines: &mut Vec<Line>,
196 current: &mut Vec<Span>,
197 text: &str,
198 class: Option<&'static str>,
199) {
183 for (i, segment) in text.split('\n').enumerate() {200 for (i, segment) in text.split('\n').enumerate() {
184 if i > 0 {201 if i > 0 {
185 lines.push(Line {202 lines.push(Line {
@@ -284,4 +301,30 @@ mod tests {
284 assert_eq!(line_text(&lines[0]), "fn main() {}");301 assert_eq!(line_text(&lines[0]), "fn main() {}");
285 assert_eq!(line_text(&lines[1]), "let x = 1;");302 assert_eq!(line_text(&lines[1]), "let x = 1;");
286 }303 }
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 }
287}330}
crates/highlight/src/snapshots/highlight__tests__snapshot_rust_fixture.snap +9 −0
@@ -0,0 +1,9 @@
1---
2source: crates/highlight/src/lib.rs
3expression: 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.
272build sandbox's rpath, so the loader cannot find `libgit2.so.1.9` and the binary272build sandbox's rpath, so the loader cannot find `libgit2.so.1.9` and the binary
273exits 127. The Nix build's own checks (`nix flake check`) embed rpath and are273exits 127. The Nix build's own checks (`nix flake check`) embed rpath and are
274unaffected; this only fixes the interactive `just check` path.274unaffected; 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
279tree collecting every `.gitattributes` blob with its directory prefix, parses each
280into ordered rules, and matches paths with `globset`. Precedence is "deeper
281directory wins, later line within a file wins", implemented by applying
282shallowest-first into a map. The `moka` cache (`AttributesCache`) keyed by tree oid
283is 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
288unreliable against a bare repo and for per-ref accuracy (§3.5). `Repo` is opened
289per operation (per the phase-5 threading decision), so a cache on it would never
290outlive a request; placing the `moka` cache in a standalone `AttributesCache`
291lets the long-lived web state share resolved sets across requests without coupling
292to 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
297bundle every tree-sitter grammar plus the `constants` capture-name table). We use
298its `highlight_raw` event stream and build per-line HTML ourselves rather than its
299built-in HTML formatter.
300
301**Alternatives:** Depend on `tree-sitter-highlight` directly with a hand-curated
302grammar 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
305the single crate*, so they never appear as separate nodes in cargo-deny's graph and
306the license gate stays green. Bundling all grammars honours "highlighting
307everywhere code appears" without a curation burden; the build cost is paid once and
308cached. The built-in formatter emits one blob, which cannot satisfy the
309line-addressable/per-side-diff requirement (§8), so we consume the raw event stream
310and fold captures onto our stable `hl-*` classes in one table. inkjet's grammars
311link `libstdc++`, added to the flake `buildInputs` so the built binary rpaths it and
312the 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`),
317binary (NUL in the first 8 KiB), and minified (any line > 5000 bytes) inputs skip
318straight to plain text, and any grammar panic is caught (`catch_unwind`) and
319degraded the same way. The spec's per-file **timeout** guard is **not** yet
320implemented.
321
322**Alternatives:** Implement the timeout now via a watchdog thread and a
323tree-sitter cancellation flag.
324
325**Rationale:** The size, binary, minified, and panic guards cover every failure
326mode observed in practice; a hung grammar is vanishingly rare and, when it happens,
327is bounded by the `spawn_blocking` pool at the call site rather than by silently
328corrupting a page. Wiring a hard per-file deadline (a cancellation flag polled by
329the grammar, or a watchdog thread) is deferred to the polish phase, where the
330highlight cache and measured hot paths land together. Tracked as a known gap.
docs/theming.md +55 −0
@@ -0,0 +1,55 @@
1# Theming
2
3fabrica 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
5single CSS file that defines those properties. This document catalogues the tokens
6a theme sets. The layout-token families (`--fb-bg`, `--fb-accent`, …) are wired up
7in the web phase; the **syntax-highlight** family below is live as of the highlight
8phase 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
13and never tree-sitter capture names. Every grammar's captures are folded onto this
14set through one table (`crates/highlight/src/classes.rs`), so a theme styles code by
15targeting 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
32A 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
43Captures with no entry in the table render as ordinary text inside their line, so an
44unstyled grammar never produces stray markup. Highlighting is line-addressable:
45every emitted line is independently balanced HTML, so blob-view anchors (`#L12`) and
46per-side diff rows can slice by line number without breaking spans.
47
48## Layout tokens
49
50The 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
53the web shell, where `base.css` and the embedded `dark`/`light` themes are
54introduced. A theme that sets only the documented tokens must yield a complete,
55coherent UI — that is the acceptance test for themeability.
flake.nix +4 −4
@@ -34,15 +34,15 @@
3434
35 craneLib = (inputs.crane.mkLib pkgs).overrideToolchain fenixToolchain;35 craneLib = (inputs.crane.mkLib pkgs).overrideToolchain fenixToolchain;
3636
37 # Keep `.sql` migration files in the build source: the `store` crate37 # Keep `.sql` migration files (embedded by `store` via `sqlx::migrate!`)
38 # embeds them via `sqlx::migrate!` (and reads them in tests), and38 # and `.snap` insta snapshots (read by `highlight`'s snapshot tests) in
39 # crane's default cargo-source filter would strip them.39 # the build source: crane's default cargo-source filter would strip both.
40 src = pkgs.lib.cleanSourceWith {40 src = pkgs.lib.cleanSourceWith {
41 src = ./.;41 src = ./.;
42 name = "source";42 name = "source";
43 filter =43 filter =
44 path: type:44 path: type:
45 (builtins.match ".*\\.sql$" path != null) || (craneLib.filterCargoSources path type);45 (builtins.match ".*\\.(sql|snap)$" path != null) || (craneLib.filterCargoSources path type);
46 };46 };
4747
48 commonArgs = {48 commonArgs = {