Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/highlight/src/classes.rs +6 −1
| @@ -56,6 +56,8 @@ pub fn class_for(capture: &str) -> Option<&'static str> { | |||
| 56 | 56 | ||
| 57 | 57 | #[cfg(test)] | |
| 58 | 58 | mod tests { | |
| 59 | + | #![allow(clippy::unwrap_used)] | |
| 60 | + | ||
| 59 | 61 | use super::*; | |
| 60 | 62 | ||
| 61 | 63 | #[test] | |
| @@ -82,7 +84,10 @@ mod tests { | |||
| 82 | 84 | fn every_class_is_documented_in_the_list() { | |
| 83 | 85 | for capture in ["keyword", "function", "type", "string", "comment"] { | |
| 84 | 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 { | |||
| 240 | 240 | ||
| 241 | 241 | #[test] | |
| 242 | 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 | } | |
| 245 | 248 | ||
| 246 | 249 | #[test] | |
| @@ -286,8 +289,14 @@ mod tests { | |||
| 286 | 289 | ||
| 287 | 290 | #[test] | |
| 288 | 291 | 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 | + | ); | |
| 291 | 300 | assert_eq!(name("flake.lock", "{}\n", None).as_deref(), Some("json")); | |
| 292 | 301 | } | |
| 293 | 302 | ||
| @@ -308,6 +317,9 @@ mod tests { | |||
| 308 | 317 | ||
| 309 | 318 | #[test] | |
| 310 | 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 | 74 | /// resolved. Oversized, binary, minified, or unrecognized-language inputs — and any | |
| 75 | 75 | /// grammar failure — degrade to plain text; the call never fails. | |
| 76 | 76 | #[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 { | |
| 78 | 82 | let lang = resolve(path, content, attr_override); | |
| 79 | 83 | if lang.is_plain() || is_unhighlightable(content) { | |
| 80 | 84 | return Highlighted { | |
| @@ -90,7 +94,10 @@ pub fn highlight(path: &std::path::Path, content: &[u8], attr_override: Option<& | |||
| 90 | 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 | 101 | Some(lines) => Highlighted { | |
| 95 | 102 | lines, | |
| 96 | 103 | language: lang.name().map(str::to_string), | |
| @@ -125,7 +132,9 @@ fn is_unhighlightable(content: &[u8]) -> bool { | |||
| 125 | 132 | if window.contains(&0) { | |
| 126 | 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 | } | |
| 130 | 139 | ||
| 131 | 140 | /// 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 | 152 | for event in events { | |
| 144 | 153 | match event.ok()? { | |
| 145 | 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 | 159 | stack.push(class); | |
| 148 | 160 | } | |
| 149 | 161 | HighlightEvent::HighlightEnd => { | |
| @@ -179,7 +191,12 @@ fn plain_lines(source: &str) -> Vec<Line> { | |||
| 179 | 191 | /// newlines. Each embedded `\n` finishes the current line — preserving blank lines | |
| 180 | 192 | /// — and the class carries onto the next line, which is the crux of producing | |
| 181 | 193 | /// 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 | + | ) { | |
| 183 | 200 | for (i, segment) in text.split('\n').enumerate() { | |
| 184 | 201 | if i > 0 { | |
| 185 | 202 | lines.push(Line { | |
| @@ -284,4 +301,30 @@ mod tests { | |||
| 284 | 301 | assert_eq!(line_text(&lines[0]), "fn main() {}"); | |
| 285 | 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 | + | --- | |
| 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. | |||
| 272 | 272 | build sandbox's rpath, so the loader cannot find `libgit2.so.1.9` and the binary | |
| 273 | 273 | exits 127. The Nix build's own checks (`nix flake check`) embed rpath and are | |
| 274 | 274 | 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 @@ | |||
| 34 | 34 | ||
| 35 | 35 | craneLib = (inputs.crane.mkLib pkgs).overrideToolchain fenixToolchain; | |
| 36 | 36 | ||
| 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. | |
| 40 | 40 | src = pkgs.lib.cleanSourceWith { | |
| 41 | 41 | src = ./.; | |
| 42 | 42 | name = "source"; | |
| 43 | 43 | filter = | |
| 44 | 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 | }; | |
| 47 | 47 | ||
| 48 | 48 | commonArgs = { | |