fabrica

hanna/fabrica

feat(git): resolve .gitattributes from tree blobs

b5815a0 · hanna committed on 2026-07-25

Add an own attribute resolver (§3.5): for a tree, collect every
.gitattributes blob with its directory prefix, parse each into ordered
rules, and match paths with globset. Precedence follows git — a deeper
directory wins over a shallower one and a later line wins within a file —
implemented by applying files shallowest-first into a map. `!key`
unspecifies (clearing a less-specific value), distinct from `-key`.

libgit2's own lookup is working-tree oriented and unreliable against a
bare repo, hence resolving from the object database directly. The moka
cache (AttributesCache) is a standalone type the web layer holds rather
than a field on the per-operation Repo handle.

Typed accessors surface the attributes fabrica consults: the highlight
language override, binary/-text, -diff, linguist/fabrica-generated, and
linguist-vendored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
11 files changed · +1487 −13UnifiedSplit
Cargo.lock +264 −12
@@ -3,6 +3,28 @@
33 version = 4
44
55 [[package]]
6+name = "ahash"
7+version = "0.8.12"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
10+dependencies = [
11+ "cfg-if",
12+ "getrandom 0.3.4",
13+ "once_cell",
14+ "version_check",
15+ "zerocopy",
16+]
17+
18+[[package]]
19+name = "aho-corasick"
20+version = "1.1.4"
21+source = "registry+https://github.com/rust-lang/crates.io-index"
22+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
23+dependencies = [
24+ "memchr",
25+]
26+
27+[[package]]
628 name = "allocator-api2"
729 version = "0.2.21"
830 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -110,7 +132,7 @@ dependencies = [
110132 "serde",
111133 "serde_json",
112134 "sha2 0.10.9",
113- "thiserror",
135+ "thiserror 2.0.19",
114136 ]
115137
116138 [[package]]
@@ -168,6 +190,16 @@ dependencies = [
168190 ]
169191
170192 [[package]]
193+name = "bstr"
194+version = "1.13.0"
195+source = "registry+https://github.com/rust-lang/crates.io-index"
196+checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"
197+dependencies = [
198+ "memchr",
199+ "serde_core",
200+]
201+
202+[[package]]
171203 name = "bumpalo"
172204 version = "3.20.3"
173205 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -304,12 +336,23 @@ version = "0.1.0"
304336 dependencies = [
305337 "figment",
306338 "serde",
307- "thiserror",
339+ "thiserror 2.0.19",
308340 "toml",
309341 "url",
310342 ]
311343
312344 [[package]]
345+name = "console"
346+version = "0.16.4"
347+source = "registry+https://github.com/rust-lang/crates.io-index"
348+checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
349+dependencies = [
350+ "encode_unicode",
351+ "libc",
352+ "windows-sys 0.61.2",
353+]
354+
355+[[package]]
313356 name = "cpufeatures"
314357 version = "0.2.17"
315358 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -343,6 +386,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
343386 checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
344387
345388 [[package]]
389+name = "crossbeam-channel"
390+version = "0.5.16"
391+source = "registry+https://github.com/rust-lang/crates.io-index"
392+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
393+dependencies = [
394+ "crossbeam-utils",
395+]
396+
397+[[package]]
398+name = "crossbeam-epoch"
399+version = "0.9.20"
400+source = "registry+https://github.com/rust-lang/crates.io-index"
401+checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
402+dependencies = [
403+ "crossbeam-utils",
404+]
405+
406+[[package]]
346407 name = "crossbeam-queue"
347408 version = "0.3.13"
348409 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -434,6 +495,12 @@ dependencies = [
434495 ]
435496
436497 [[package]]
498+name = "encode_unicode"
499+version = "1.0.0"
500+source = "registry+https://github.com/rust-lang/crates.io-index"
501+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
502+
503+[[package]]
437504 name = "equivalent"
438505 version = "1.0.2"
439506 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -652,10 +719,12 @@ name = "git"
652719 version = "0.1.0"
653720 dependencies = [
654721 "git2",
722+ "globset",
655723 "libgit2-sys",
656724 "model",
725+ "moka",
657726 "tempfile",
658- "thiserror",
727+ "thiserror 2.0.19",
659728 ]
660729
661730 [[package]]
@@ -672,6 +741,19 @@ dependencies = [
672741 ]
673742
674743 [[package]]
744+name = "globset"
745+version = "0.4.19"
746+source = "registry+https://github.com/rust-lang/crates.io-index"
747+checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd"
748+dependencies = [
749+ "aho-corasick",
750+ "bstr",
751+ "log",
752+ "regex-automata",
753+ "regex-syntax",
754+]
755+
756+[[package]]
675757 name = "hashbrown"
676758 version = "0.16.1"
677759 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -712,6 +794,10 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
712794 [[package]]
713795 name = "highlight"
714796 version = "0.1.0"
797+dependencies = [
798+ "inkjet",
799+ "insta",
800+]
715801
716802 [[package]]
717803 name = "hkdf"
@@ -862,12 +948,41 @@ dependencies = [
862948 ]
863949
864950 [[package]]
951+name = "inkjet"
952+version = "0.11.1"
953+source = "registry+https://github.com/rust-lang/crates.io-index"
954+checksum = "56b828db0cd62bd220b32745e71f5cf8818af2e20d8cb499a7e221dd91cb323a"
955+dependencies = [
956+ "ahash",
957+ "anyhow",
958+ "cc",
959+ "serde",
960+ "thiserror 1.0.69",
961+ "toml",
962+ "tree-sitter",
963+ "tree-sitter-highlight",
964+ "v_htmlescape",
965+]
966+
967+[[package]]
865968 name = "inlinable_string"
866969 version = "0.1.15"
867970 source = "registry+https://github.com/rust-lang/crates.io-index"
868971 checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
869972
870973 [[package]]
974+name = "insta"
975+version = "1.48.0"
976+source = "registry+https://github.com/rust-lang/crates.io-index"
977+checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"
978+dependencies = [
979+ "console",
980+ "once_cell",
981+ "similar",
982+ "tempfile",
983+]
984+
985+[[package]]
871986 name = "is_terminal_polyfill"
872987 version = "1.70.2"
873988 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -901,6 +1016,12 @@ dependencies = [
9011016 ]
9021017
9031018 [[package]]
1019+name = "lazy_static"
1020+version = "1.5.0"
1021+source = "registry+https://github.com/rust-lang/crates.io-index"
1022+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1023+
1024+[[package]]
9041025 name = "libc"
9051026 version = "0.2.189"
9061027 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1003,7 +1124,24 @@ dependencies = [
10031124 name = "model"
10041125 version = "0.1.0"
10051126 dependencies = [
1006- "thiserror",
1127+ "thiserror 2.0.19",
1128+]
1129+
1130+[[package]]
1131+name = "moka"
1132+version = "0.12.15"
1133+source = "registry+https://github.com/rust-lang/crates.io-index"
1134+checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
1135+dependencies = [
1136+ "crossbeam-channel",
1137+ "crossbeam-epoch",
1138+ "crossbeam-utils",
1139+ "equivalent",
1140+ "parking_lot",
1141+ "portable-atomic",
1142+ "smallvec",
1143+ "tagptr",
1144+ "uuid",
10071145 ]
10081146
10091147 [[package]]
@@ -1109,6 +1247,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
11091247 checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
11101248
11111249 [[package]]
1250+name = "portable-atomic"
1251+version = "1.14.0"
1252+source = "registry+https://github.com/rust-lang/crates.io-index"
1253+checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
1254+
1255+[[package]]
11121256 name = "potential_utf"
11131257 version = "0.1.5"
11141258 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1234,6 +1378,35 @@ dependencies = [
12341378 ]
12351379
12361380 [[package]]
1381+name = "regex"
1382+version = "1.13.1"
1383+source = "registry+https://github.com/rust-lang/crates.io-index"
1384+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
1385+dependencies = [
1386+ "aho-corasick",
1387+ "memchr",
1388+ "regex-automata",
1389+ "regex-syntax",
1390+]
1391+
1392+[[package]]
1393+name = "regex-automata"
1394+version = "0.4.16"
1395+source = "registry+https://github.com/rust-lang/crates.io-index"
1396+checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
1397+dependencies = [
1398+ "aho-corasick",
1399+ "memchr",
1400+ "regex-syntax",
1401+]
1402+
1403+[[package]]
1404+name = "regex-syntax"
1405+version = "0.8.11"
1406+source = "registry+https://github.com/rust-lang/crates.io-index"
1407+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1408+
1409+[[package]]
12371410 name = "rpassword"
12381411 version = "7.5.4"
12391412 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1371,6 +1544,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
13711544 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
13721545
13731546 [[package]]
1547+name = "similar"
1548+version = "2.7.0"
1549+source = "registry+https://github.com/rust-lang/crates.io-index"
1550+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
1551+
1552+[[package]]
13741553 name = "slab"
13751554 version = "0.4.12"
13761555 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1444,7 +1623,7 @@ dependencies = [
14441623 "serde_json",
14451624 "sha2 0.10.9",
14461625 "smallvec",
1447- "thiserror",
1626+ "thiserror 2.0.19",
14481627 "tokio",
14491628 "tokio-stream",
14501629 "tracing",
@@ -1484,7 +1663,7 @@ dependencies = [
14841663 "sqlx-postgres",
14851664 "sqlx-sqlite",
14861665 "syn 2.0.119",
1487- "thiserror",
1666+ "thiserror 2.0.19",
14881667 "tokio",
14891668 "url",
14901669 ]
@@ -1511,7 +1690,7 @@ dependencies = [
15111690 "sha1",
15121691 "sha2 0.11.0",
15131692 "sqlx-core",
1514- "thiserror",
1693+ "thiserror 2.0.19",
15151694 "tracing",
15161695 ]
15171696
@@ -1545,7 +1724,7 @@ dependencies = [
15451724 "smallvec",
15461725 "sqlx-core",
15471726 "stringprep",
1548- "thiserror",
1727+ "thiserror 2.0.19",
15491728 "tracing",
15501729 "whoami",
15511730 ]
@@ -1569,7 +1748,7 @@ dependencies = [
15691748 "percent-encoding",
15701749 "serde",
15711750 "sqlx-core",
1572- "thiserror",
1751+ "thiserror 2.0.19",
15731752 "tracing",
15741753 "url",
15751754 ]
@@ -1591,7 +1770,7 @@ dependencies = [
15911770 "model",
15921771 "sqlx",
15931772 "tempfile",
1594- "thiserror",
1773+ "thiserror 2.0.19",
15951774 "tokio",
15961775 "ulid",
15971776 ]
@@ -1653,13 +1832,19 @@ dependencies = [
16531832 ]
16541833
16551834 [[package]]
1835+name = "tagptr"
1836+version = "0.2.0"
1837+source = "registry+https://github.com/rust-lang/crates.io-index"
1838+checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
1839+
1840+[[package]]
16561841 name = "tempfile"
16571842 version = "3.27.0"
16581843 source = "registry+https://github.com/rust-lang/crates.io-index"
16591844 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
16601845 dependencies = [
16611846 "fastrand",
1662- "getrandom 0.3.4",
1847+ "getrandom 0.4.3",
16631848 "once_cell",
16641849 "rustix",
16651850 "windows-sys 0.61.2",
@@ -1667,11 +1852,31 @@ dependencies = [
16671852
16681853 [[package]]
16691854 name = "thiserror"
1855+version = "1.0.69"
1856+source = "registry+https://github.com/rust-lang/crates.io-index"
1857+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
1858+dependencies = [
1859+ "thiserror-impl 1.0.69",
1860+]
1861+
1862+[[package]]
1863+name = "thiserror"
16701864 version = "2.0.19"
16711865 source = "registry+https://github.com/rust-lang/crates.io-index"
16721866 checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
16731867 dependencies = [
1674- "thiserror-impl",
1868+ "thiserror-impl 2.0.19",
1869+]
1870+
1871+[[package]]
1872+name = "thiserror-impl"
1873+version = "1.0.69"
1874+source = "registry+https://github.com/rust-lang/crates.io-index"
1875+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
1876+dependencies = [
1877+ "proc-macro2",
1878+ "quote",
1879+ "syn 2.0.119",
16751880 ]
16761881
16771882 [[package]]
@@ -1821,6 +2026,36 @@ dependencies = [
18212026 ]
18222027
18232028 [[package]]
2029+name = "tree-sitter"
2030+version = "0.23.2"
2031+source = "registry+https://github.com/rust-lang/crates.io-index"
2032+checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca"
2033+dependencies = [
2034+ "cc",
2035+ "regex",
2036+ "regex-syntax",
2037+ "tree-sitter-language",
2038+]
2039+
2040+[[package]]
2041+name = "tree-sitter-highlight"
2042+version = "0.23.2"
2043+source = "registry+https://github.com/rust-lang/crates.io-index"
2044+checksum = "380a7706376fa6c52ba7bf71d1e7a93856ee8ab08a7680631dfa664fdd237d66"
2045+dependencies = [
2046+ "lazy_static",
2047+ "regex",
2048+ "thiserror 1.0.69",
2049+ "tree-sitter",
2050+]
2051+
2052+[[package]]
2053+name = "tree-sitter-language"
2054+version = "0.1.7"
2055+source = "registry+https://github.com/rust-lang/crates.io-index"
2056+checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
2057+
2058+[[package]]
18242059 name = "typenum"
18252060 version = "1.20.1"
18262061 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1897,6 +2132,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
18972132 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
18982133
18992134 [[package]]
2135+name = "uuid"
2136+version = "1.24.0"
2137+source = "registry+https://github.com/rust-lang/crates.io-index"
2138+checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
2139+dependencies = [
2140+ "getrandom 0.4.3",
2141+ "js-sys",
2142+ "wasm-bindgen",
2143+]
2144+
2145+[[package]]
2146+name = "v_htmlescape"
2147+version = "0.15.8"
2148+source = "registry+https://github.com/rust-lang/crates.io-index"
2149+checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
2150+
2151+[[package]]
19002152 name = "vcpkg"
19012153 version = "0.2.15"
19022154 source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml +9 −0
@@ -87,6 +87,15 @@ base64 = "0.22"
8787 rand_core = { version = "0.6", features = ["getrandom"] }
8888 # Interactive, non-echoing password entry for the CLI.
8989 rpassword = "7"
90+# Glob matching for the `.gitattributes` resolver (gitignore-style patterns).
91+globset = "0.4"
92+# Bounded, concurrent caches (attribute rule sets, highlight output, signatures).
93+moka = { version = "0.12", features = ["sync"] }
94+# Syntax highlighting over tree-sitter, with bundled grammars. Default features
95+# carry all grammars plus the constants table we map onto our `hl-*` classes.
96+inkjet = "0.11.1"
97+# Snapshot testing.
98+insta = "1"
9099
91100 [workspace.lints.rust]
92101 unsafe_code = "forbid"
crates/git/Cargo.toml +2 −0
@@ -12,6 +12,8 @@ publish.workspace = true
1212 [dependencies]
1313 model = { workspace = true }
1414 git2 = { workspace = true }
15+globset = { workspace = true }
16+moka = { workspace = true }
1517 thiserror = { workspace = true }
1618 # Pin the libgit2 bindings to a build that requires libgit2 <= the version
1719 # nixpkgs ships (1.9.4). git2 0.20's default libgit2-sys (0.18.7, bundling 1.9.6)
crates/git/src/attributes.rs +512 −0
@@ -0,0 +1,512 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! A `.gitattributes` resolver over tree blobs.
6+//!
7+//! libgit2's attribute lookup is oriented at a working tree; against a bare
8+//! repository, and for per-ref accuracy, it is unreliable. This module resolves
9+//! attributes directly from the object database instead: for a given tree it
10+//! collects every `.gitattributes` blob with its directory prefix, parses each
11+//! into ordered rules, and answers "what attributes apply to this path?" with git's
12+//! precedence — deeper directories win over shallower, and within one file a later
13+//! line wins over an earlier one.
14+//!
15+//! Pattern matching follows gitattributes/gitignore semantics: a pattern with no
16+//! slash matches the basename at any depth below its file; a pattern containing a
17+//! slash is anchored to its file's directory; `**` spans directories; `!key`,
18+//! `-key`, `key`, and `key=value` are the four assignment forms.
19+//!
20+//! Consumers (later phases): the highlight language override
21+//! (`fabrica-language` / `linguist-language`), the "binary, not shown" decision
22+//! (`binary` / `-text`), collapsing generated diffs (`linguist-generated` /
23+//! `fabrica-generated`), and excluding vendored paths from search
24+//! (`linguist-vendored`).
25+
26+use std::collections::HashMap;
27+use std::sync::Arc;
28+
29+use globset::{GlobBuilder, GlobMatcher};
30+
31+use crate::GitError;
32+use crate::repo::Repo;
33+use crate::types::Oid;
34+
35+/// The value an attribute is assigned by a matching rule.
36+///
37+/// Mirrors git's four forms. Resolution keeps the last (deepest, latest) value
38+/// per attribute name; [`AttrValue::Unspecified`] deliberately clears a value set
39+/// by a shallower rule.
40+#[derive(Debug, Clone, PartialEq, Eq)]
41+pub enum AttrValue {
42+ /// `attr` — set to the implicit boolean true.
43+ Set,
44+ /// `-attr` — explicitly unset (boolean false).
45+ Unset,
46+ /// `!attr` — unspecified; removes any value a less specific rule assigned.
47+ Unspecified,
48+ /// `attr=value` — set to a string value.
49+ Value(String),
50+}
51+
52+/// The attributes resolved for one concrete path.
53+///
54+/// A thin wrapper over the final name→value map, with typed accessors for the
55+/// attributes fabrica actually consults.
56+#[derive(Debug, Clone, Default, PartialEq, Eq)]
57+pub struct Attributes {
58+ map: HashMap<String, AttrValue>,
59+}
60+
61+impl Attributes {
62+ /// The raw value assigned to `name`, if any rule set one.
63+ #[must_use]
64+ pub fn get(&self, name: &str) -> Option<&AttrValue> {
65+ self.map.get(name)
66+ }
67+
68+ /// Whether `name` resolves to a truthy state ([`AttrValue::Set`] or a
69+ /// non-`false` [`AttrValue::Value`]).
70+ #[must_use]
71+ pub fn is_set(&self, name: &str) -> bool {
72+ match self.map.get(name) {
73+ Some(AttrValue::Set) => true,
74+ Some(AttrValue::Value(v)) => v != "false",
75+ _ => false,
76+ }
77+ }
78+
79+ /// Whether `name` was explicitly unset (`-name`).
80+ #[must_use]
81+ pub fn is_unset(&self, name: &str) -> bool {
82+ matches!(self.map.get(name), Some(AttrValue::Unset))
83+ }
84+
85+ /// The highlighting language override, honouring fabrica's own attribute over
86+ /// GitHub's `linguist-language` (§8.1 precedence).
87+ #[must_use]
88+ pub fn language(&self) -> Option<&str> {
89+ for key in ["fabrica-language", "linguist-language"] {
90+ if let Some(AttrValue::Value(v)) = self.map.get(key) {
91+ return Some(v.as_str());
92+ }
93+ }
94+ None
95+ }
96+
97+ /// Whether the path should be treated as binary (rendered as "binary file, not
98+ /// shown"): `binary` set, or text explicitly disabled with `-text`.
99+ #[must_use]
100+ pub fn is_binary(&self) -> bool {
101+ self.is_set("binary") || self.is_unset("text")
102+ }
103+
104+ /// Whether diff rendering is suppressed for this path: `-diff`, or `binary`
105+ /// (git's `binary` macro implies `-diff`).
106+ #[must_use]
107+ pub fn no_diff(&self) -> bool {
108+ self.is_unset("diff") || self.is_set("binary")
109+ }
110+
111+ /// Whether the path is machine-generated and should collapse by default.
112+ #[must_use]
113+ pub fn is_generated(&self) -> bool {
114+ self.is_set("linguist-generated") || self.is_set("fabrica-generated")
115+ }
116+
117+ /// Whether the path is vendored and should be excluded from search.
118+ #[must_use]
119+ pub fn is_vendored(&self) -> bool {
120+ self.is_set("linguist-vendored")
121+ }
122+}
123+
124+/// One `.gitattributes` file: its directory prefix (`""` at the tree root) and its
125+/// parsed rules in file order.
126+#[derive(Debug)]
127+struct AttrFile {
128+ /// Directory the file lives in, without a trailing slash (`""` at the root).
129+ dir: String,
130+ /// The file's rules, in the order they appear.
131+ rules: Vec<Rule>,
132+}
133+
134+/// One parsed line of a `.gitattributes` file.
135+#[derive(Debug)]
136+struct Rule {
137+ /// The compiled pattern.
138+ matcher: GlobMatcher,
139+ /// Whether the pattern floats (no slash → match the basename at any depth)
140+ /// rather than being anchored to the file's directory.
141+ floating: bool,
142+ /// The attribute assignments this line makes, in order.
143+ assigns: Vec<(String, AttrValue)>,
144+}
145+
146+/// The resolved rule set for a whole tree: every `.gitattributes` file it
147+/// contains, ordered shallowest-first so application order yields git's
148+/// "deeper wins" precedence.
149+#[derive(Debug, Default)]
150+pub struct AttributesSet {
151+ files: Vec<AttrFile>,
152+}
153+
154+impl AttributesSet {
155+ /// Resolve the attributes that apply to `path` (a repo-relative, `/`-separated
156+ /// path with no leading slash).
157+ #[must_use]
158+ pub fn attributes(&self, path: &str) -> Attributes {
159+ let path = path.trim_start_matches('/');
160+ let mut map: HashMap<String, AttrValue> = HashMap::new();
161+
162+ // `files` is shallowest-first; applying in that order lets deeper files
163+ // (and later lines within a file) overwrite, which is exactly git's rule.
164+ for file in &self.files {
165+ let Some(rel) = relative_to(path, &file.dir) else {
166+ continue;
167+ };
168+ let rel_base = rel.rsplit('/').next().unwrap_or(rel);
169+ for rule in &file.rules {
170+ let hit = if rule.floating {
171+ rule.matcher.is_match(rel_base)
172+ } else {
173+ rule.matcher.is_match(rel)
174+ };
175+ if hit {
176+ for (name, value) in &rule.assigns {
177+ match value {
178+ AttrValue::Unspecified => {
179+ map.remove(name);
180+ }
181+ other => {
182+ map.insert(name.clone(), other.clone());
183+ }
184+ }
185+ }
186+ }
187+ }
188+ }
189+ Attributes { map }
190+ }
191+}
192+
193+/// Return `path` relative to directory `dir`, or `None` if `path` is not under
194+/// `dir`. `dir` is `""` for the tree root (everything is under it).
195+fn relative_to<'a>(path: &'a str, dir: &str) -> Option<&'a str> {
196+ if dir.is_empty() {
197+ return Some(path);
198+ }
199+ path.strip_prefix(dir)?.strip_prefix('/')
200+}
201+
202+/// Parse the text of one `.gitattributes` file at directory `dir` into rules.
203+/// Unparseable patterns are skipped (logged by the caller's fall-through), never
204+/// fatal.
205+fn parse_attr_file(dir: &str, text: &str) -> AttrFile {
206+ let mut rules = Vec::new();
207+ for raw in text.lines() {
208+ let line = raw.trim();
209+ if line.is_empty() || line.starts_with('#') {
210+ continue;
211+ }
212+ let mut fields = line.split_whitespace();
213+ let Some(pattern) = fields.next() else {
214+ continue;
215+ };
216+ let assigns: Vec<(String, AttrValue)> = fields.map(parse_assignment).collect();
217+ if assigns.is_empty() {
218+ continue;
219+ }
220+ if let Some((matcher, floating)) = compile_pattern(pattern) {
221+ rules.push(Rule {
222+ matcher,
223+ floating,
224+ assigns,
225+ });
226+ }
227+ }
228+ AttrFile {
229+ dir: dir.to_string(),
230+ rules,
231+ }
232+}
233+
234+/// Parse one whitespace-delimited attribute token into a name and value.
235+fn parse_assignment(token: &str) -> (String, AttrValue) {
236+ if let Some(name) = token.strip_prefix('!') {
237+ (name.to_string(), AttrValue::Unspecified)
238+ } else if let Some(name) = token.strip_prefix('-') {
239+ (name.to_string(), AttrValue::Unset)
240+ } else if let Some((name, value)) = token.split_once('=') {
241+ (name.to_string(), AttrValue::Value(value.to_string()))
242+ } else {
243+ (token.to_string(), AttrValue::Set)
244+ }
245+}
246+
247+/// Compile a gitattributes pattern into a matcher plus whether it floats. Returns
248+/// `None` for a pattern globset cannot compile.
249+///
250+/// Semantics: a pattern with no `/` (a trailing one aside) floats and is matched
251+/// against the basename; a pattern with a slash is anchored to the file's
252+/// directory. `literal_separator(true)` makes `*` stop at `/` while `**` spans
253+/// directories, matching git.
254+fn compile_pattern(pattern: &str) -> Option<(GlobMatcher, bool)> {
255+ let trimmed = pattern.trim_end_matches('/');
256+ let floating = !trimmed.contains('/');
257+ let glob_src = trimmed.strip_prefix('/').unwrap_or(trimmed);
258+ let glob = GlobBuilder::new(glob_src)
259+ .literal_separator(!floating)
260+ .build()
261+ .ok()?;
262+ Some((glob.compile_matcher(), floating))
263+}
264+
265+impl Repo {
266+ /// Collect and parse every `.gitattributes` file reachable in the tree of
267+ /// commit `oid`, yielding a queryable [`AttributesSet`].
268+ ///
269+ /// # Errors
270+ ///
271+ /// Returns [`GitError::RevNotFound`] if `oid` is not a commit, or
272+ /// [`GitError::Libgit2`] on a walk failure.
273+ pub fn attributes_set(&self, oid: &Oid) -> Result<AttributesSet, GitError> {
274+ let commit = self.commit_at(oid)?;
275+ let tree = commit.tree()?;
276+
277+ // Collect (dir, blob_oid) for every `.gitattributes`, then read the blobs.
278+ let mut found: Vec<(String, git2::Oid)> = Vec::new();
279+ tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| {
280+ if entry.name() == Some(".gitattributes")
281+ && entry.filemode() != i32::from(git2::FileMode::Tree)
282+ {
283+ // `root` is the directory prefix, e.g. "" or "src/".
284+ found.push((root.trim_end_matches('/').to_string(), entry.id()));
285+ }
286+ git2::TreeWalkResult::Ok
287+ })?;
288+
289+ let mut files: Vec<AttrFile> = Vec::with_capacity(found.len());
290+ for (dir, blob_oid) in found {
291+ let blob = self.raw().find_blob(blob_oid)?;
292+ let text = String::from_utf8_lossy(blob.content());
293+ files.push(parse_attr_file(&dir, &text));
294+ }
295+ // Shallowest directory first so that applying rules in order lets deeper
296+ // files override.
297+ files.sort_by(|a, b| {
298+ depth(&a.dir)
299+ .cmp(&depth(&b.dir))
300+ .then_with(|| a.dir.cmp(&b.dir))
301+ });
302+ Ok(AttributesSet { files })
303+ }
304+}
305+
306+/// The nesting depth of a directory prefix (`""` → 0, `"src"` → 1, `"a/b"` → 2).
307+fn depth(dir: &str) -> usize {
308+ if dir.is_empty() {
309+ 0
310+ } else {
311+ dir.split('/').count()
312+ }
313+}
314+
315+/// A bounded cache of resolved [`AttributesSet`]s keyed by tree/commit oid.
316+///
317+/// [`Repo`] is opened per operation, so the cache lives here — shared across
318+/// requests by the web layer — rather than on the repository handle. Resolving a
319+/// tree's attributes parses every `.gitattributes` blob it contains, which is
320+/// wasteful to repeat per blob view within one commit.
321+#[derive(Clone)]
322+pub struct AttributesCache {
323+ inner: moka::sync::Cache<String, Arc<AttributesSet>>,
324+}
325+
326+impl AttributesCache {
327+ /// A cache holding up to `capacity` resolved sets.
328+ #[must_use]
329+ pub fn new(capacity: u64) -> Self {
330+ Self {
331+ inner: moka::sync::Cache::new(capacity),
332+ }
333+ }
334+
335+ /// Fetch the resolved set for `oid` in `repo`, resolving and caching on a miss.
336+ ///
337+ /// # Errors
338+ ///
339+ /// Propagates any [`GitError`] from resolution on a cache miss.
340+ pub fn get(&self, repo: &Repo, oid: &Oid) -> Result<Arc<AttributesSet>, GitError> {
341+ if let Some(hit) = self.inner.get(oid.as_str()) {
342+ return Ok(hit);
343+ }
344+ let set = Arc::new(repo.attributes_set(oid)?);
345+ self.inner.insert(oid.to_string(), Arc::clone(&set));
346+ Ok(set)
347+ }
348+}
349+
350+impl Default for AttributesCache {
351+ fn default() -> Self {
352+ Self::new(256)
353+ }
354+}
355+
356+#[cfg(test)]
357+mod tests {
358+ #![allow(clippy::unwrap_used, clippy::expect_used)]
359+
360+ use super::*;
361+
362+ /// Build an [`AttributesSet`] from `(dir, text)` pairs, mimicking what a tree
363+ /// walk would collect, so the resolution logic can be tested without a repo.
364+ fn set_from(files: &[(&str, &str)]) -> AttributesSet {
365+ let mut parsed: Vec<AttrFile> = files
366+ .iter()
367+ .map(|(dir, text)| parse_attr_file(dir, text))
368+ .collect();
369+ parsed.sort_by(|a, b| {
370+ depth(&a.dir)
371+ .cmp(&depth(&b.dir))
372+ .then_with(|| a.dir.cmp(&b.dir))
373+ });
374+ AttributesSet { files: parsed }
375+ }
376+
377+ #[test]
378+ fn floating_pattern_matches_basename_at_any_depth() {
379+ let set = set_from(&[("", "*.rs linguist-language=Rust\n")]);
380+ assert_eq!(set.attributes("src/deep/main.rs").language(), Some("Rust"));
381+ assert_eq!(set.attributes("main.rs").language(), Some("Rust"));
382+ assert_eq!(set.attributes("main.py").language(), None);
383+ }
384+
385+ #[test]
386+ fn anchored_pattern_only_matches_its_directory_level() {
387+ // A leading slash anchors to the file's directory; `*` does not cross `/`.
388+ let set = set_from(&[("", "/*.rs linguist-language=Rust\n")]);
389+ assert_eq!(set.attributes("main.rs").language(), Some("Rust"));
390+ assert_eq!(set.attributes("src/main.rs").language(), None);
391+ }
392+
393+ #[test]
394+ fn double_star_spans_directories() {
395+ let set = set_from(&[("", "src/**/*.rs linguist-language=Rust\n")]);
396+ assert_eq!(set.attributes("src/a/b/x.rs").language(), Some("Rust"));
397+ assert_eq!(set.attributes("other/x.rs").language(), None);
398+ }
399+
400+ #[test]
401+ fn deeper_file_wins_over_shallower() {
402+ // Root marks everything generated; a nested file un-marks its subtree.
403+ let set = set_from(&[
404+ ("", "* linguist-generated\n"),
405+ ("src", "* -linguist-generated\n"),
406+ ]);
407+ assert!(set.attributes("README.md").is_generated());
408+ assert!(!set.attributes("src/main.rs").is_generated());
409+ }
410+
411+ #[test]
412+ fn later_line_wins_within_a_file() {
413+ let set = set_from(&[(
414+ "",
415+ "*.rs linguist-language=Rust\nmain.rs linguist-language=Ruby\n",
416+ )]);
417+ assert_eq!(set.attributes("main.rs").language(), Some("Ruby"));
418+ assert_eq!(set.attributes("lib.rs").language(), Some("Rust"));
419+ }
420+
421+ #[test]
422+ fn negation_clears_a_value_from_a_less_specific_rule() {
423+ // `!key` unspecifies, distinct from `-key` which sets false.
424+ let set = set_from(&[("", "* linguist-language=Text\n*.rs !linguist-language\n")]);
425+ assert_eq!(set.attributes("notes.md").language(), Some("Text"));
426+ assert_eq!(set.attributes("main.rs").language(), None);
427+ }
428+
429+ #[test]
430+ fn binary_and_minus_text_are_binary() {
431+ let set = set_from(&[("", "*.bin binary\n*.dat -text\n")]);
432+ assert!(set.attributes("x.bin").is_binary());
433+ assert!(set.attributes("x.dat").is_binary());
434+ assert!(!set.attributes("x.txt").is_binary());
435+ }
436+
437+ #[test]
438+ fn minus_diff_suppresses_diff_rendering() {
439+ let set = set_from(&[("", "*.lock -diff\n")]);
440+ assert!(set.attributes("Cargo.lock").no_diff());
441+ assert!(!set.attributes("src/main.rs").no_diff());
442+ // `binary` also implies no diff.
443+ let bin = set_from(&[("", "*.png binary\n")]);
444+ assert!(bin.attributes("logo.png").no_diff());
445+ }
446+
447+ #[test]
448+ fn comments_and_blanks_are_ignored() {
449+ let set = set_from(&[("", "# a comment\n\n*.rs linguist-language=Rust\n")]);
450+ assert_eq!(set.attributes("main.rs").language(), Some("Rust"));
451+ }
452+
453+ #[test]
454+ fn fabrica_language_beats_linguist_language() {
455+ let set = set_from(&[(
456+ "",
457+ "*.rs linguist-language=Rust\n*.rs fabrica-language=RustCustom\n",
458+ )]);
459+ assert_eq!(set.attributes("main.rs").language(), Some("RustCustom"));
460+ }
461+
462+ #[test]
463+ fn attributes_set_reads_gitattributes_from_a_real_tree() {
464+ use std::path::Path;
465+
466+ use git2::{Repository, Signature, Time};
467+
468+ use crate::{create_bare, repo_path};
469+
470+ let dir = tempfile::tempdir().unwrap();
471+ let id = "01hzxk9m2n8p7q6r5s4t3v2w1x";
472+ create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).unwrap();
473+ let path = repo_path(dir.path(), id).unwrap();
474+ let raw = Repository::open_bare(&path).unwrap();
475+
476+ // Root `.gitattributes` marks vendored; a nested one overrides in `src`.
477+ let root_attr = raw
478+ .blob(b"vendor/** linguist-vendored\n*.rs linguist-language=Rust\n")
479+ .unwrap();
480+ let src_attr = raw.blob(b"*.rs -linguist-vendored\n").unwrap();
481+ let code = raw.blob(b"fn main() {}\n").unwrap();
482+
483+ let mut src = raw.treebuilder(None).unwrap();
484+ src.insert(".gitattributes", src_attr, 0o100_644).unwrap();
485+ src.insert("main.rs", code, 0o100_644).unwrap();
486+ let src_tree = src.write().unwrap();
487+
488+ let mut vendor = raw.treebuilder(None).unwrap();
489+ vendor.insert("lib.rs", code, 0o100_644).unwrap();
490+ let vendor_tree = vendor.write().unwrap();
491+
492+ let mut root = raw.treebuilder(None).unwrap();
493+ root.insert(".gitattributes", root_attr, 0o100_644).unwrap();
494+ root.insert("src", src_tree, 0o040_000).unwrap();
495+ root.insert("vendor", vendor_tree, 0o040_000).unwrap();
496+ let root_tree = raw.find_tree(root.write().unwrap()).unwrap();
497+
498+ let sig = Signature::new("Ada", "ada@example.com", &Time::new(1, 0)).unwrap();
499+ let commit = raw
500+ .commit(Some("refs/heads/main"), &sig, &sig, "init", &root_tree, &[])
501+ .unwrap();
502+
503+ let repo = Repo::open_path(&path).unwrap();
504+ let set = repo.attributes_set(&Oid::new(commit.to_string())).unwrap();
505+
506+ // `vendor/lib.rs` is vendored; `src/main.rs` is un-vendored by the nested
507+ // file but still resolves its language from the root rule.
508+ assert!(set.attributes("vendor/lib.rs").is_vendored());
509+ assert!(!set.attributes("src/main.rs").is_vendored());
510+ assert_eq!(set.attributes("src/main.rs").language(), Some("Rust"));
511+ }
512+}
crates/git/src/lib.rs +2 −0
@@ -27,6 +27,7 @@
2727 //! name, so renames and group moves are metadata-only. The database is the sole
2828 //! name→path authority; [`repo_path`] is the one place that mapping is computed.
2929
30+mod attributes;
3031 mod repo;
3132 mod types;
3233
@@ -37,6 +38,7 @@ use std::path::{Path, PathBuf};
3738
3839 use git2::{Repository, RepositoryInitOptions};
3940
41+pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet};
4042 pub use crate::repo::Repo;
4143 pub use crate::types::{
4244 Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind,
crates/git/src/repo.rs +7 −1
@@ -473,8 +473,14 @@ impl Repo {
473473 }
474474 }
475475
476+ /// The underlying libgit2 handle, for sibling modules in this crate (e.g. the
477+ /// attributes resolver). Never exposed outside the crate.
478+ pub(crate) fn raw(&self) -> &Repository {
479+ &self.inner
480+ }
481+
476482 /// Look up a commit by our [`Oid`], mapping a miss to [`GitError::RevNotFound`].
477- fn commit_at(&self, oid: &Oid) -> Result<Commit<'_>, GitError> {
483+ pub(crate) fn commit_at(&self, oid: &Oid) -> Result<Commit<'_>, GitError> {
478484 let parsed = git2::Oid::from_str(oid.as_str())
479485 .map_err(|_| GitError::RevNotFound(oid.to_string()))?;
480486 self.inner
crates/highlight/Cargo.toml +4 −0
@@ -10,6 +10,10 @@ repository.workspace = true
1010 publish.workspace = true
1111
1212 [dependencies]
13+inkjet = { workspace = true }
14+
15+[dev-dependencies]
16+insta = { workspace = true }
1317
1418 [lints]
1519 workspace = true
crates/highlight/src/classes.rs +88 −0
@@ -0,0 +1,88 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! The one table mapping tree-sitter capture names onto fabrica's stable, themeable
6+//! CSS class names.
7+//!
8+//! Themes style code by these class names alone and never need to know tree-sitter
9+//! internals; the full list is documented in `docs/theming.md`. Capture names are
10+//! hierarchical and dotted (`keyword.control.return`, `constant.numeric.integer`);
11+//! we map on the first component, with `constant.numeric*` special-cased to the
12+//! number class. Captures with no mapping render unhighlighted (no wrapping span).
13+
14+/// The complete set of highlight classes a theme may style. Kept in sync with the
15+/// `--fb-hl-*` token family in `docs/theming.md`.
16+pub const HL_CLASSES: &[&str] = &[
17+ "hl-keyword",
18+ "hl-function",
19+ "hl-type",
20+ "hl-string",
21+ "hl-number",
22+ "hl-comment",
23+ "hl-constant",
24+ "hl-variable",
25+ "hl-operator",
26+ "hl-punctuation",
27+ "hl-attribute",
28+ "hl-tag",
29+];
30+
31+/// Map a tree-sitter capture name to a fabrica highlight class, or `None` when the
32+/// capture has no styling (it renders as plain text within its line).
33+#[must_use]
34+pub fn class_for(capture: &str) -> Option<&'static str> {
35+ // `constant.numeric.*` is a constant in the grammar but a number to us.
36+ if capture.starts_with("constant.numeric") {
37+ return Some("hl-number");
38+ }
39+ let head = capture.split('.').next().unwrap_or(capture);
40+ Some(match head {
41+ "keyword" | "label" => "hl-keyword",
42+ "function" | "method" => "hl-function",
43+ "type" | "constructor" | "namespace" => "hl-type",
44+ "string" | "escape" | "char" | "regex" => "hl-string",
45+ "number" | "float" | "boolean" => "hl-number",
46+ "comment" => "hl-comment",
47+ "constant" => "hl-constant",
48+ "variable" | "property" | "parameter" | "field" => "hl-variable",
49+ "operator" => "hl-operator",
50+ "punctuation" => "hl-punctuation",
51+ "attribute" | "annotation" | "decorator" => "hl-attribute",
52+ "tag" => "hl-tag",
53+ _ => return None,
54+ })
55+}
56+
57+#[cfg(test)]
58+mod tests {
59+ use super::*;
60+
61+ #[test]
62+ fn maps_common_captures() {
63+ assert_eq!(class_for("keyword"), Some("hl-keyword"));
64+ assert_eq!(class_for("keyword.control.return"), Some("hl-keyword"));
65+ assert_eq!(class_for("function.macro"), Some("hl-function"));
66+ assert_eq!(class_for("type.builtin"), Some("hl-type"));
67+ assert_eq!(class_for("string"), Some("hl-string"));
68+ assert_eq!(class_for("constant.numeric.integer"), Some("hl-number"));
69+ assert_eq!(class_for("constant.builtin"), Some("hl-constant"));
70+ assert_eq!(class_for("punctuation.delimiter"), Some("hl-punctuation"));
71+ assert_eq!(class_for("variable.parameter"), Some("hl-variable"));
72+ }
73+
74+ #[test]
75+ fn unmapped_captures_are_none() {
76+ assert_eq!(class_for("markup.heading"), None);
77+ assert_eq!(class_for("diff.plus"), None);
78+ assert_eq!(class_for("something.unknown"), None);
79+ }
80+
81+ #[test]
82+ fn every_class_is_documented_in_the_list() {
83+ for capture in ["keyword", "function", "type", "string", "comment"] {
84+ let class = class_for(capture).unwrap();
85+ assert!(HL_CLASSES.contains(&class), "{class} missing from HL_CLASSES");
86+ }
87+ }
88+}
crates/highlight/src/lang.rs +313 −0
@@ -0,0 +1,313 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Language resolution (§8.1).
6+//!
7+//! Precedence, highest first: an explicit `.gitattributes` override
8+//! (`fabrica-language` / `linguist-language`, resolved by the git layer and passed
9+//! in), a shebang, a vim/emacs modeline, an exact filename, the longest matching
10+//! extension, then plain text. Name matching is case-insensitive and defers to
11+//! `inkjet`'s own token table (which already knows `rs`/`rust`, `py`/`python`, …),
12+//! with a small alias table on top for names it lacks. An unknown name simply
13+//! falls through rather than erroring.
14+
15+use std::path::Path;
16+
17+use inkjet::Language;
18+
19+/// A resolved language: either a concrete grammar with the token it was named by,
20+/// or plain text. Opaque so callers never depend on `inkjet` directly.
21+#[derive(Clone)]
22+pub struct Lang {
23+ language: Option<Language>,
24+ name: Option<String>,
25+}
26+
27+impl Lang {
28+ /// The plain-text language (no highlighting).
29+ #[must_use]
30+ pub fn plain() -> Self {
31+ Self {
32+ language: None,
33+ name: None,
34+ }
35+ }
36+
37+ /// Whether this is plain text (nothing to highlight).
38+ #[must_use]
39+ pub fn is_plain(&self) -> bool {
40+ self.language.is_none()
41+ }
42+
43+ /// The display name of the resolved language (the token it matched), if any.
44+ #[must_use]
45+ pub fn name(&self) -> Option<&str> {
46+ self.name.as_deref()
47+ }
48+
49+ /// The underlying grammar, for the highlighter.
50+ pub(crate) fn language(&self) -> Option<Language> {
51+ self.language
52+ }
53+
54+ /// Build from a token, consulting fabrica's alias table then inkjet's.
55+ fn from_token(token: &str) -> Option<Self> {
56+ let token = token.trim().to_ascii_lowercase();
57+ if token.is_empty() {
58+ return None;
59+ }
60+ let canonical = ALIASES
61+ .iter()
62+ .find(|(alias, _)| *alias == token)
63+ .map_or(token.as_str(), |(_, canonical)| *canonical);
64+ Language::from_token(canonical).map(|language| Self {
65+ language: Some(language),
66+ name: Some(canonical.to_string()),
67+ })
68+ }
69+}
70+
71+/// Aliases inkjet's own token table does not carry. Checked before inkjet.
72+const ALIASES: &[(&str, &str)] = &[
73+ ("terraform", "hcl"),
74+ ("tf", "hcl"),
75+ ("shell", "bash"),
76+ ("sh", "bash"),
77+ ("yml", "yaml"),
78+ ("rs", "rust"),
79+ ("md", "markdown"),
80+];
81+
82+/// Exact filenames that name a language regardless of extension.
83+const FILENAMES: &[(&str, &str)] = &[
84+ ("dockerfile", "dockerfile"),
85+ ("containerfile", "dockerfile"),
86+ ("makefile", "make"),
87+ ("gnumakefile", "make"),
88+ ("cmakelists.txt", "cmake"),
89+ ("flake.lock", "json"),
90+ ("cargo.lock", "toml"),
91+ ("go.mod", "gomod"),
92+ ("go.sum", "gosum"),
93+ ("pkgbuild", "bash"),
94+ (".gitconfig", "gitconfig"),
95+ (".gitattributes", "gitattributes"),
96+ (".gitignore", "gitignore"),
97+];
98+
99+/// Resolve the language of `path` given its `content` and any attribute override.
100+///
101+/// `attr_override` is the `fabrica-language`/`linguist-language` value the git
102+/// attribute resolver produced (already in precedence order); an unrecognized name
103+/// falls through to the content- and name-based steps rather than erroring.
104+#[must_use]
105+pub fn resolve(path: &Path, content: &[u8], attr_override: Option<&str>) -> Lang {
106+ // 1–2. Attribute override.
107+ if let Some(name) = attr_override
108+ && let Some(lang) = Lang::from_token(name)
109+ {
110+ return lang;
111+ }
112+
113+ let filename = path
114+ .file_name()
115+ .and_then(|n| n.to_str())
116+ .unwrap_or_default();
117+
118+ // Text-derived hints (shebang, modeline) only make sense for textual content.
119+ let text = std::str::from_utf8(content).ok();
120+
121+ // 4. Shebang.
122+ if let Some(text) = text
123+ && let Some(lang) = shebang_language(text)
124+ {
125+ return lang;
126+ }
127+
128+ // 5. Modeline.
129+ if let Some(text) = text
130+ && let Some(lang) = modeline_language(text)
131+ {
132+ return lang;
133+ }
134+
135+ // 6. Exact filename.
136+ let lower = filename.to_ascii_lowercase();
137+ if let Some((_, token)) = FILENAMES.iter().find(|(name, _)| *name == lower)
138+ && let Some(lang) = Lang::from_token(token)
139+ {
140+ return lang;
141+ }
142+
143+ // 7. Extension, longest compound first (`d.ts` before `ts`).
144+ if let Some(lang) = extension_language(filename) {
145+ return lang;
146+ }
147+
148+ // 8. Plain text.
149+ Lang::plain()
150+}
151+
152+/// Try each dotted suffix of `filename` from longest to shortest as a language
153+/// token: `archive.tar.gz` yields `tar.gz` then `gz`.
154+fn extension_language(filename: &str) -> Option<Lang> {
155+ let parts: Vec<&str> = filename.split('.').collect();
156+ // Skip index 0 (the stem); each later index starts a candidate suffix.
157+ for start in 1..parts.len() {
158+ let candidate = parts[start..].join(".");
159+ if let Some(lang) = Lang::from_token(&candidate) {
160+ return Some(lang);
161+ }
162+ }
163+ None
164+}
165+
166+/// Extract a language from a `#!` first line, normalizing `env python3` → `python`.
167+fn shebang_language(text: &str) -> Option<Lang> {
168+ let first = text.lines().next()?;
169+ let rest = first.strip_prefix("#!")?;
170+ let mut words = rest.split_whitespace();
171+ let mut interp = words.next()?;
172+ // `/usr/bin/env python` → take the interpreter after `env`.
173+ let base = interp.rsplit('/').next().unwrap_or(interp);
174+ if base == "env" {
175+ interp = words.next()?;
176+ } else {
177+ interp = base;
178+ }
179+ // Strip a trailing version suffix: `python3` → `python`, `ruby2.7` → `ruby`.
180+ let name: String = interp
181+ .trim_end_matches(|c: char| c.is_ascii_digit() || c == '.')
182+ .to_string();
183+ Lang::from_token(if name.is_empty() { interp } else { &name })
184+}
185+
186+/// Extract a language from a vim (`vim: set ft=python:`) or emacs
187+/// (`-*- mode: python -*-`) modeline in the first or last five lines.
188+fn modeline_language(text: &str) -> Option<Lang> {
189+ let lines: Vec<&str> = text.lines().collect();
190+ let head = lines.iter().take(5);
191+ let tail = lines.iter().rev().take(5);
192+ for line in head.chain(tail) {
193+ if let Some(lang) = parse_modeline(line) {
194+ return Some(lang);
195+ }
196+ }
197+ None
198+}
199+
200+/// Parse one line for a vim `ft=`/`filetype=` or emacs `mode:` directive.
201+fn parse_modeline(line: &str) -> Option<Lang> {
202+ // vim: look for `ft=NAME` or `filetype=NAME`.
203+ for key in ["filetype=", "ft="] {
204+ if let Some(idx) = line.find(key) {
205+ let after = &line[idx + key.len()..];
206+ let value: String = after
207+ .chars()
208+ .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
209+ .collect();
210+ if let Some(lang) = Lang::from_token(&value) {
211+ return Some(lang);
212+ }
213+ }
214+ }
215+ // emacs: `-*- ... mode: NAME ... -*-`.
216+ if line.contains("-*-")
217+ && let Some(idx) = line.find("mode:")
218+ {
219+ let after = line[idx + "mode:".len()..].trim_start();
220+ let value: String = after
221+ .chars()
222+ .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
223+ .collect();
224+ if let Some(lang) = Lang::from_token(&value) {
225+ return Some(lang);
226+ }
227+ }
228+ None
229+}
230+
231+#[cfg(test)]
232+mod tests {
233+ use super::*;
234+
235+ fn name(path: &str, content: &str, over: Option<&str>) -> Option<String> {
236+ resolve(Path::new(path), content.as_bytes(), over)
237+ .name()
238+ .map(str::to_string)
239+ }
240+
241+ #[test]
242+ fn extension_resolves_rust() {
243+ assert_eq!(name("src/main.rs", "fn main() {}", None).as_deref(), Some("rust"));
244+ }
245+
246+ #[test]
247+ fn attribute_override_beats_extension() {
248+ // A `.rs` file forced to python by an attribute.
249+ assert_eq!(
250+ name("weird.rs", "fn main() {}", Some("python")).as_deref(),
251+ Some("python")
252+ );
253+ }
254+
255+ #[test]
256+ fn unknown_override_falls_through_to_extension() {
257+ assert_eq!(
258+ name("main.rs", "fn main() {}", Some("no-such-language")).as_deref(),
259+ Some("rust")
260+ );
261+ }
262+
263+ #[test]
264+ fn shebang_resolves_and_strips_version() {
265+ assert_eq!(
266+ name("script", "#!/usr/bin/env python3\nprint(1)\n", None).as_deref(),
267+ Some("python")
268+ );
269+ assert_eq!(
270+ name("run", "#!/bin/bash\necho hi\n", None).as_deref(),
271+ Some("bash")
272+ );
273+ }
274+
275+ #[test]
276+ fn modeline_vim_and_emacs() {
277+ assert_eq!(
278+ name("notes", "# vim: set ft=ruby:\nputs 1\n", None).as_deref(),
279+ Some("ruby")
280+ );
281+ assert_eq!(
282+ name("notes", "-*- mode: python -*-\nx = 1\n", None).as_deref(),
283+ Some("python")
284+ );
285+ }
286+
287+ #[test]
288+ 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"));
291+ assert_eq!(name("flake.lock", "{}\n", None).as_deref(), Some("json"));
292+ }
293+
294+ #[test]
295+ fn compound_extension_resolves_via_a_suffix() {
296+ // `types.d.ts` resolves by trying `d.ts` then `ts`; either way it is a
297+ // recognized language, not plain text.
298+ let lang = resolve(Path::new("types.d.ts"), b"export {}\n", None);
299+ assert!(!lang.is_plain());
300+ }
301+
302+ #[test]
303+ fn unknown_extension_is_plain() {
304+ let lang = resolve(Path::new("data.zzz"), b"noise", None);
305+ assert!(lang.is_plain());
306+ assert_eq!(lang.name(), None);
307+ }
308+
309+ #[test]
310+ fn alias_table_maps_terraform() {
311+ assert_eq!(name("main.tf", "resource {}\n", None).as_deref(), Some("hcl"));
312+ }
313+}
crates/highlight/src/lib.rs +282 −0
@@ -3,3 +3,285 @@
33 // file, You can obtain one at https://mozilla.org/MPL/2.0/.
44
55 //! Syntax highlighting producing independently-valid HTML per line.
6+//!
7+//! Two hard requirements drive the design. First, **line-addressable output**: the
8+//! blob view anchors on line numbers, so highlighting yields a `Vec<`[`Line`]`>`
9+//! where each line is a `Vec<`[`Span`]`>` that a template renders into balanced
10+//! markup on its own — a span that crosses a newline is split and its class
11+//! reopened on the next line. Second, **per-side diff highlighting**: a diff
12+//! highlights the pre- and post-image of a file as whole documents (never
13+//! per-hunk, which would feed a context-dependent grammar garbage) and then zips
14+//! the resulting lines with the hunks.
15+//!
16+//! Highlighting **must not** be able to fail a page render (§8): oversized, binary,
17+//! and minified inputs skip straight to plain text, and a grammar panic is caught
18+//! and degraded the same way. The output carries no colours — only the stable
19+//! `hl-*` classes from [`classes`], which themes style.
20+
21+mod classes;
22+mod lang;
23+
24+use std::panic::{AssertUnwindSafe, catch_unwind};
25+
26+use inkjet::Highlighter;
27+use inkjet::constants::HIGHLIGHT_NAMES;
28+use inkjet::tree_sitter_highlight::HighlightEvent;
29+
30+pub use crate::classes::{HL_CLASSES, class_for};
31+pub use crate::lang::{Lang, resolve};
32+
33+/// Files larger than this are rendered as plain text (matches the
34+/// `ui.max_highlight_bytes` default; the web layer may pre-filter with the
35+/// configured value).
36+pub const DEFAULT_MAX_BYTES: usize = 1_048_576;
37+
38+/// A single line longer than this marks the file minified; it is rendered plain.
39+pub const MINIFIED_LINE_BYTES: usize = 5_000;
40+
41+/// Bytes sniffed from the head of the content when deciding whether it is binary.
42+const BINARY_SNIFF_BYTES: usize = 8_192;
43+
44+/// One styled run of text within a line. `class` is a stable `hl-*` name or `None`
45+/// for unstyled text; `text` is raw (the renderer escapes it) and never contains a
46+/// newline.
47+#[derive(Debug, Clone, PartialEq, Eq)]
48+pub struct Span {
49+ /// The highlight class, or `None` for text with no styling.
50+ pub class: Option<&'static str>,
51+ /// The raw text of this run, newline-free.
52+ pub text: String,
53+}
54+
55+/// One rendered source line: its spans in order, without the trailing newline.
56+#[derive(Debug, Clone, PartialEq, Eq, Default)]
57+pub struct Line {
58+ /// The line's styled runs, left to right.
59+ pub spans: Vec<Span>,
60+}
61+
62+/// The result of highlighting a whole blob.
63+#[derive(Debug, Clone, PartialEq, Eq)]
64+pub struct Highlighted {
65+ /// One entry per source line.
66+ pub lines: Vec<Line>,
67+ /// The resolved language's display name, or `None` when rendered plain.
68+ pub language: Option<String>,
69+}
70+
71+/// Highlight a blob for the file at `path`, applying every guard.
72+///
73+/// `attr_override` is the `.gitattributes` language override the git layer
74+/// resolved. Oversized, binary, minified, or unrecognized-language inputs — and any
75+/// grammar failure — degrade to plain text; the call never fails.
76+#[must_use]
77+pub fn highlight(path: &std::path::Path, content: &[u8], attr_override: Option<&str>) -> Highlighted {
78+ let lang = resolve(path, content, attr_override);
79+ if lang.is_plain() || is_unhighlightable(content) {
80+ return Highlighted {
81+ lines: plain_lines(&String::from_utf8_lossy(content)),
82+ language: None,
83+ };
84+ }
85+ // `is_unhighlightable` already rejected non-UTF-8 (a NUL sniff), but guard the
86+ // decode anyway; a lossy fallback would misalign byte offsets.
87+ let Ok(source) = std::str::from_utf8(content) else {
88+ return Highlighted {
89+ lines: plain_lines(&String::from_utf8_lossy(content)),
90+ language: None,
91+ };
92+ };
93+ match lang.language().and_then(|inner| highlight_events(inner, source)) {
94+ Some(lines) => Highlighted {
95+ lines,
96+ language: lang.name().map(str::to_string),
97+ },
98+ None => Highlighted {
99+ lines: plain_lines(source),
100+ language: None,
101+ },
102+ }
103+}
104+
105+/// Highlight a whole document (one side of a diff) with an already-resolved
106+/// language, for zipping with hunks. Applies the size and minified guards and falls
107+/// back to plain text on anything unhighlightable.
108+#[must_use]
109+pub fn highlight_document(lang: &Lang, source: &str) -> Vec<Line> {
110+ if lang.is_plain() || is_unhighlightable(source.as_bytes()) {
111+ return plain_lines(source);
112+ }
113+ lang.language()
114+ .and_then(|inner| highlight_events(inner, source))
115+ .unwrap_or_else(|| plain_lines(source))
116+}
117+
118+/// Whether `content` should skip highlighting outright: too large, binary (a NUL in
119+/// the sniff window), or minified (a very long line).
120+fn is_unhighlightable(content: &[u8]) -> bool {
121+ if content.len() > DEFAULT_MAX_BYTES {
122+ return true;
123+ }
124+ let window = &content[..content.len().min(BINARY_SNIFF_BYTES)];
125+ if window.contains(&0) {
126+ return true;
127+ }
128+ content.split(|b| *b == b'\n').any(|line| line.len() > MINIFIED_LINE_BYTES)
129+}
130+
131+/// Run the grammar and fold its events into lines, or `None` on any grammar error
132+/// or panic (the caller falls back to plain text).
133+fn highlight_events(language: inkjet::Language, source: &str) -> Option<Vec<Line>> {
134+ let outcome = catch_unwind(AssertUnwindSafe(|| {
135+ let mut highlighter = Highlighter::new();
136+ let events = highlighter.highlight_raw(language, &source).ok()?;
137+
138+ let mut lines: Vec<Line> = Vec::new();
139+ let mut current: Vec<Span> = Vec::new();
140+ // A stack of classes, innermost last; `None` for captures we do not style.
141+ let mut stack: Vec<Option<&'static str>> = Vec::new();
142+
143+ for event in events {
144+ match event.ok()? {
145+ HighlightEvent::HighlightStart(highlight) => {
146+ let class = HIGHLIGHT_NAMES.get(highlight.0).copied().and_then(class_for);
147+ stack.push(class);
148+ }
149+ HighlightEvent::HighlightEnd => {
150+ stack.pop();
151+ }
152+ HighlightEvent::Source { start, end } => {
153+ let text = source.get(start..end).unwrap_or_default();
154+ let class = stack.iter().rev().flatten().next().copied();
155+ push_text(&mut lines, &mut current, text, class);
156+ }
157+ }
158+ }
159+ if !current.is_empty() {
160+ lines.push(Line { spans: current });
161+ }
162+ Some(lines)
163+ }));
164+ outcome.ok().flatten()
165+}
166+
167+/// Render unhighlighted `source` into one plain span per line.
168+fn plain_lines(source: &str) -> Vec<Line> {
169+ let mut lines: Vec<Line> = Vec::new();
170+ let mut current: Vec<Span> = Vec::new();
171+ push_text(&mut lines, &mut current, source, None);
172+ if !current.is_empty() {
173+ lines.push(Line { spans: current });
174+ }
175+ lines
176+}
177+
178+/// Append `text` (with class `class`) to the line being built, splitting on
179+/// 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
181+/// 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>) {
183+ for (i, segment) in text.split('\n').enumerate() {
184+ if i > 0 {
185+ lines.push(Line {
186+ spans: std::mem::take(current),
187+ });
188+ }
189+ if !segment.is_empty() {
190+ current.push(Span {
191+ class,
192+ text: segment.to_string(),
193+ });
194+ }
195+ }
196+}
197+
198+#[cfg(test)]
199+mod tests {
200+ #![allow(clippy::unwrap_used)]
201+
202+ use std::path::Path;
203+
204+ use super::*;
205+
206+ /// Flatten a line back to its text, for assertions independent of span layout.
207+ fn line_text(line: &Line) -> String {
208+ line.spans.iter().map(|s| s.text.as_str()).collect()
209+ }
210+
211+ #[test]
212+ fn highlights_rust_and_marks_keywords() {
213+ let out = highlight(Path::new("main.rs"), b"fn main() {}\n", None);
214+ assert_eq!(out.language.as_deref(), Some("rust"));
215+ assert_eq!(out.lines.len(), 1);
216+ // `fn` should carry the keyword class somewhere on the line.
217+ let classes: Vec<_> = out.lines[0].spans.iter().filter_map(|s| s.class).collect();
218+ assert!(classes.contains(&"hl-keyword"), "classes: {classes:?}");
219+ assert_eq!(line_text(&out.lines[0]), "fn main() {}");
220+ }
221+
222+ #[test]
223+ fn every_line_is_independently_balanced_across_a_multiline_string() {
224+ // A multi-line string literal: the string class must reopen on each line,
225+ // so every line stands alone with balanced spans.
226+ let src = "fn f() -> &'static str {\n \"line one\nline two\nline three\"\n}\n";
227+ let out = highlight(Path::new("s.rs"), src.as_bytes(), None);
228+ // The three literal lines each carry the string class.
229+ let string_lines: Vec<usize> = out
230+ .lines
231+ .iter()
232+ .enumerate()
233+ .filter(|(_, l)| l.spans.iter().any(|s| s.class == Some("hl-string")))
234+ .map(|(i, _)| i)
235+ .collect();
236+ assert!(
237+ string_lines.len() >= 3,
238+ "string spans should reopen on each continuation line: {string_lines:?}"
239+ );
240+ // No span text ever contains a newline.
241+ for line in &out.lines {
242+ for span in &line.spans {
243+ assert!(!span.text.contains('\n'), "span leaked a newline: {span:?}");
244+ }
245+ }
246+ }
247+
248+ #[test]
249+ fn preserves_blank_lines_and_line_count() {
250+ let out = highlight(Path::new("a.rs"), b"fn a() {}\n\nfn b() {}\n", None);
251+ assert_eq!(out.lines.len(), 3);
252+ assert_eq!(line_text(&out.lines[1]), "");
253+ }
254+
255+ #[test]
256+ fn binary_content_degrades_to_plain() {
257+ let out = highlight(Path::new("x.rs"), b"fn main() {\0}\n", None);
258+ assert_eq!(out.language, None, "NUL byte marks it binary → plain");
259+ }
260+
261+ #[test]
262+ fn minified_content_degrades_to_plain() {
263+ let mut src = String::from("let x = ");
264+ src.push_str(&"a".repeat(MINIFIED_LINE_BYTES + 1));
265+ src.push('\n');
266+ let out = highlight(Path::new("min.js"), src.as_bytes(), None);
267+ assert_eq!(out.language, None);
268+ }
269+
270+ #[test]
271+ fn unknown_language_is_plain_but_still_line_split() {
272+ let out = highlight(Path::new("notes.zzz"), b"one\ntwo\n", None);
273+ assert_eq!(out.language, None);
274+ assert_eq!(out.lines.len(), 2);
275+ assert_eq!(line_text(&out.lines[0]), "one");
276+ assert!(out.lines[0].spans.iter().all(|s| s.class.is_none()));
277+ }
278+
279+ #[test]
280+ fn highlight_document_matches_source_lines() {
281+ let lang = resolve(Path::new("main.rs"), b"fn main() {}\n", None);
282+ let lines = highlight_document(&lang, "fn main() {}\nlet x = 1;\n");
283+ assert_eq!(lines.len(), 2);
284+ assert_eq!(line_text(&lines[0]), "fn main() {}");
285+ assert_eq!(line_text(&lines[1]), "let x = 1;");
286+ }
287+}
flake.nix +4 −0
@@ -56,6 +56,10 @@
5656 pkgs.libgit2
5757 pkgs.zlib
5858 pkgs.openssl
59+ # inkjet compiles tree-sitter grammars (some C++), whose objects link
60+ # libstdc++ dynamically; keep it in the closure so the built binary
61+ # rpaths it and interactive `cargo test` finds it via LD_LIBRARY_PATH.
62+ pkgs.stdenv.cc.cc.lib
5963 ];
6064 # Use the system libgit2 rather than the vendored copy.
6165 LIBGIT2_NO_VENDOR = "1";