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 | #[cfg(test)] | 57 | #[cfg(test)] |
| 58 | mod tests { | 58 | mod tests { |
| 59 | #![allow(clippy::unwrap_used)] | ||
| 60 | |||
| 59 | use super::*; | 61 | use super::*; |
| 60 | 62 | ||
| 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 { | |||
| 240 | 240 | ||
| 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 | } |
| 245 | 248 | ||
| 246 | #[test] | 249 | #[test] |
| @@ -286,8 +289,14 @@ mod tests { | |||
| 286 | 289 | ||
| 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 | } |
| 293 | 302 | ||
| @@ -308,6 +317,9 @@ mod tests { | |||
| 308 | 317 | ||
| 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 any | 74 | /// 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] |
| 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 | 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 | } |
| 130 | 139 | ||
| 131 | /// Run the grammar and fold its events into lines, or `None` on any grammar error | 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 | 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 lines | 191 | /// 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 producing | 192 | /// — 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. |
| 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 | 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 | --- | ||
| 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 | build sandbox's rpath, so the loader cannot find `libgit2.so.1.9` and the binary | 272 | build sandbox's rpath, so the loader cannot find `libgit2.so.1.9` and the binary |
| 273 | exits 127. The Nix build's own checks (`nix flake check`) embed rpath and are | 273 | exits 127. The Nix build's own checks (`nix flake check`) embed rpath and are |
| 274 | unaffected; this only fixes the interactive `just check` path. | 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 | craneLib = (inputs.crane.mkLib pkgs).overrideToolchain fenixToolchain; | 35 | craneLib = (inputs.crane.mkLib pkgs).overrideToolchain fenixToolchain; |
| 36 | 36 | ||
| 37 | # Keep `.sql` migration files in the build source: the `store` crate | 37 | # Keep `.sql` migration files (embedded by `store` via `sqlx::migrate!`) |
| 38 | # embeds them via `sqlx::migrate!` (and reads them in tests), and | 38 | # 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 | }; |
| 47 | 47 | ||
| 48 | commonArgs = { | 48 | commonArgs = { |