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 @@
3version = 43version = 4
44
5[[package]]5[[package]]
6name = "ahash"
7version = "0.8.12"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
10dependencies = [
11 "cfg-if",
12 "getrandom 0.3.4",
13 "once_cell",
14 "version_check",
15 "zerocopy",
16]
17
18[[package]]
19name = "aho-corasick"
20version = "1.1.4"
21source = "registry+https://github.com/rust-lang/crates.io-index"
22checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
23dependencies = [
24 "memchr",
25]
26
27[[package]]
6name = "allocator-api2"28name = "allocator-api2"
7version = "0.2.21"29version = "0.2.21"
8source = "registry+https://github.com/rust-lang/crates.io-index"30source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -110,7 +132,7 @@ dependencies = [
110 "serde",132 "serde",
111 "serde_json",133 "serde_json",
112 "sha2 0.10.9",134 "sha2 0.10.9",
113 "thiserror",135 "thiserror 2.0.19",
114]136]
115137
116[[package]]138[[package]]
@@ -168,6 +190,16 @@ dependencies = [
168]190]
169191
170[[package]]192[[package]]
193name = "bstr"
194version = "1.13.0"
195source = "registry+https://github.com/rust-lang/crates.io-index"
196checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"
197dependencies = [
198 "memchr",
199 "serde_core",
200]
201
202[[package]]
171name = "bumpalo"203name = "bumpalo"
172version = "3.20.3"204version = "3.20.3"
173source = "registry+https://github.com/rust-lang/crates.io-index"205source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -304,12 +336,23 @@ version = "0.1.0"
304dependencies = [336dependencies = [
305 "figment",337 "figment",
306 "serde",338 "serde",
307 "thiserror",339 "thiserror 2.0.19",
308 "toml",340 "toml",
309 "url",341 "url",
310]342]
311343
312[[package]]344[[package]]
345name = "console"
346version = "0.16.4"
347source = "registry+https://github.com/rust-lang/crates.io-index"
348checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
349dependencies = [
350 "encode_unicode",
351 "libc",
352 "windows-sys 0.61.2",
353]
354
355[[package]]
313name = "cpufeatures"356name = "cpufeatures"
314version = "0.2.17"357version = "0.2.17"
315source = "registry+https://github.com/rust-lang/crates.io-index"358source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -343,6 +386,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
343checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"386checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
344387
345[[package]]388[[package]]
389name = "crossbeam-channel"
390version = "0.5.16"
391source = "registry+https://github.com/rust-lang/crates.io-index"
392checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
393dependencies = [
394 "crossbeam-utils",
395]
396
397[[package]]
398name = "crossbeam-epoch"
399version = "0.9.20"
400source = "registry+https://github.com/rust-lang/crates.io-index"
401checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
402dependencies = [
403 "crossbeam-utils",
404]
405
406[[package]]
346name = "crossbeam-queue"407name = "crossbeam-queue"
347version = "0.3.13"408version = "0.3.13"
348source = "registry+https://github.com/rust-lang/crates.io-index"409source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -434,6 +495,12 @@ dependencies = [
434]495]
435496
436[[package]]497[[package]]
498name = "encode_unicode"
499version = "1.0.0"
500source = "registry+https://github.com/rust-lang/crates.io-index"
501checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
502
503[[package]]
437name = "equivalent"504name = "equivalent"
438version = "1.0.2"505version = "1.0.2"
439source = "registry+https://github.com/rust-lang/crates.io-index"506source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -652,10 +719,12 @@ name = "git"
652version = "0.1.0"719version = "0.1.0"
653dependencies = [720dependencies = [
654 "git2",721 "git2",
722 "globset",
655 "libgit2-sys",723 "libgit2-sys",
656 "model",724 "model",
725 "moka",
657 "tempfile",726 "tempfile",
658 "thiserror",727 "thiserror 2.0.19",
659]728]
660729
661[[package]]730[[package]]
@@ -672,6 +741,19 @@ dependencies = [
672]741]
673742
674[[package]]743[[package]]
744name = "globset"
745version = "0.4.19"
746source = "registry+https://github.com/rust-lang/crates.io-index"
747checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd"
748dependencies = [
749 "aho-corasick",
750 "bstr",
751 "log",
752 "regex-automata",
753 "regex-syntax",
754]
755
756[[package]]
675name = "hashbrown"757name = "hashbrown"
676version = "0.16.1"758version = "0.16.1"
677source = "registry+https://github.com/rust-lang/crates.io-index"759source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -712,6 +794,10 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
712[[package]]794[[package]]
713name = "highlight"795name = "highlight"
714version = "0.1.0"796version = "0.1.0"
797dependencies = [
798 "inkjet",
799 "insta",
800]
715801
716[[package]]802[[package]]
717name = "hkdf"803name = "hkdf"
@@ -862,12 +948,41 @@ dependencies = [
862]948]
863949
864[[package]]950[[package]]
951name = "inkjet"
952version = "0.11.1"
953source = "registry+https://github.com/rust-lang/crates.io-index"
954checksum = "56b828db0cd62bd220b32745e71f5cf8818af2e20d8cb499a7e221dd91cb323a"
955dependencies = [
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]]
865name = "inlinable_string"968name = "inlinable_string"
866version = "0.1.15"969version = "0.1.15"
867source = "registry+https://github.com/rust-lang/crates.io-index"970source = "registry+https://github.com/rust-lang/crates.io-index"
868checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"971checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
869972
870[[package]]973[[package]]
974name = "insta"
975version = "1.48.0"
976source = "registry+https://github.com/rust-lang/crates.io-index"
977checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"
978dependencies = [
979 "console",
980 "once_cell",
981 "similar",
982 "tempfile",
983]
984
985[[package]]
871name = "is_terminal_polyfill"986name = "is_terminal_polyfill"
872version = "1.70.2"987version = "1.70.2"
873source = "registry+https://github.com/rust-lang/crates.io-index"988source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -901,6 +1016,12 @@ dependencies = [
901]1016]
9021017
903[[package]]1018[[package]]
1019name = "lazy_static"
1020version = "1.5.0"
1021source = "registry+https://github.com/rust-lang/crates.io-index"
1022checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1023
1024[[package]]
904name = "libc"1025name = "libc"
905version = "0.2.189"1026version = "0.2.189"
906source = "registry+https://github.com/rust-lang/crates.io-index"1027source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1003,7 +1124,24 @@ dependencies = [
1003name = "model"1124name = "model"
1004version = "0.1.0"1125version = "0.1.0"
1005dependencies = [1126dependencies = [
1006 "thiserror",1127 "thiserror 2.0.19",
1128]
1129
1130[[package]]
1131name = "moka"
1132version = "0.12.15"
1133source = "registry+https://github.com/rust-lang/crates.io-index"
1134checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
1135dependencies = [
1136 "crossbeam-channel",
1137 "crossbeam-epoch",
1138 "crossbeam-utils",
1139 "equivalent",
1140 "parking_lot",
1141 "portable-atomic",
1142 "smallvec",
1143 "tagptr",
1144 "uuid",
1007]1145]
10081146
1009[[package]]1147[[package]]
@@ -1109,6 +1247,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1109checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"1247checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
11101248
1111[[package]]1249[[package]]
1250name = "portable-atomic"
1251version = "1.14.0"
1252source = "registry+https://github.com/rust-lang/crates.io-index"
1253checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
1254
1255[[package]]
1112name = "potential_utf"1256name = "potential_utf"
1113version = "0.1.5"1257version = "0.1.5"
1114source = "registry+https://github.com/rust-lang/crates.io-index"1258source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1234,6 +1378,35 @@ dependencies = [
1234]1378]
12351379
1236[[package]]1380[[package]]
1381name = "regex"
1382version = "1.13.1"
1383source = "registry+https://github.com/rust-lang/crates.io-index"
1384checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
1385dependencies = [
1386 "aho-corasick",
1387 "memchr",
1388 "regex-automata",
1389 "regex-syntax",
1390]
1391
1392[[package]]
1393name = "regex-automata"
1394version = "0.4.16"
1395source = "registry+https://github.com/rust-lang/crates.io-index"
1396checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
1397dependencies = [
1398 "aho-corasick",
1399 "memchr",
1400 "regex-syntax",
1401]
1402
1403[[package]]
1404name = "regex-syntax"
1405version = "0.8.11"
1406source = "registry+https://github.com/rust-lang/crates.io-index"
1407checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1408
1409[[package]]
1237name = "rpassword"1410name = "rpassword"
1238version = "7.5.4"1411version = "7.5.4"
1239source = "registry+https://github.com/rust-lang/crates.io-index"1412source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1371,6 +1544,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1371checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"1544checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
13721545
1373[[package]]1546[[package]]
1547name = "similar"
1548version = "2.7.0"
1549source = "registry+https://github.com/rust-lang/crates.io-index"
1550checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
1551
1552[[package]]
1374name = "slab"1553name = "slab"
1375version = "0.4.12"1554version = "0.4.12"
1376source = "registry+https://github.com/rust-lang/crates.io-index"1555source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1444,7 +1623,7 @@ dependencies = [
1444 "serde_json",1623 "serde_json",
1445 "sha2 0.10.9",1624 "sha2 0.10.9",
1446 "smallvec",1625 "smallvec",
1447 "thiserror",1626 "thiserror 2.0.19",
1448 "tokio",1627 "tokio",
1449 "tokio-stream",1628 "tokio-stream",
1450 "tracing",1629 "tracing",
@@ -1484,7 +1663,7 @@ dependencies = [
1484 "sqlx-postgres",1663 "sqlx-postgres",
1485 "sqlx-sqlite",1664 "sqlx-sqlite",
1486 "syn 2.0.119",1665 "syn 2.0.119",
1487 "thiserror",1666 "thiserror 2.0.19",
1488 "tokio",1667 "tokio",
1489 "url",1668 "url",
1490]1669]
@@ -1511,7 +1690,7 @@ dependencies = [
1511 "sha1",1690 "sha1",
1512 "sha2 0.11.0",1691 "sha2 0.11.0",
1513 "sqlx-core",1692 "sqlx-core",
1514 "thiserror",1693 "thiserror 2.0.19",
1515 "tracing",1694 "tracing",
1516]1695]
15171696
@@ -1545,7 +1724,7 @@ dependencies = [
1545 "smallvec",1724 "smallvec",
1546 "sqlx-core",1725 "sqlx-core",
1547 "stringprep",1726 "stringprep",
1548 "thiserror",1727 "thiserror 2.0.19",
1549 "tracing",1728 "tracing",
1550 "whoami",1729 "whoami",
1551]1730]
@@ -1569,7 +1748,7 @@ dependencies = [
1569 "percent-encoding",1748 "percent-encoding",
1570 "serde",1749 "serde",
1571 "sqlx-core",1750 "sqlx-core",
1572 "thiserror",1751 "thiserror 2.0.19",
1573 "tracing",1752 "tracing",
1574 "url",1753 "url",
1575]1754]
@@ -1591,7 +1770,7 @@ dependencies = [
1591 "model",1770 "model",
1592 "sqlx",1771 "sqlx",
1593 "tempfile",1772 "tempfile",
1594 "thiserror",1773 "thiserror 2.0.19",
1595 "tokio",1774 "tokio",
1596 "ulid",1775 "ulid",
1597]1776]
@@ -1653,13 +1832,19 @@ dependencies = [
1653]1832]
16541833
1655[[package]]1834[[package]]
1835name = "tagptr"
1836version = "0.2.0"
1837source = "registry+https://github.com/rust-lang/crates.io-index"
1838checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
1839
1840[[package]]
1656name = "tempfile"1841name = "tempfile"
1657version = "3.27.0"1842version = "3.27.0"
1658source = "registry+https://github.com/rust-lang/crates.io-index"1843source = "registry+https://github.com/rust-lang/crates.io-index"
1659checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"1844checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
1660dependencies = [1845dependencies = [
1661 "fastrand",1846 "fastrand",
1662 "getrandom 0.3.4",1847 "getrandom 0.4.3",
1663 "once_cell",1848 "once_cell",
1664 "rustix",1849 "rustix",
1665 "windows-sys 0.61.2",1850 "windows-sys 0.61.2",
@@ -1667,11 +1852,31 @@ dependencies = [
16671852
1668[[package]]1853[[package]]
1669name = "thiserror"1854name = "thiserror"
1855version = "1.0.69"
1856source = "registry+https://github.com/rust-lang/crates.io-index"
1857checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
1858dependencies = [
1859 "thiserror-impl 1.0.69",
1860]
1861
1862[[package]]
1863name = "thiserror"
1670version = "2.0.19"1864version = "2.0.19"
1671source = "registry+https://github.com/rust-lang/crates.io-index"1865source = "registry+https://github.com/rust-lang/crates.io-index"
1672checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"1866checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
1673dependencies = [1867dependencies = [
1674 "thiserror-impl",1868 "thiserror-impl 2.0.19",
1869]
1870
1871[[package]]
1872name = "thiserror-impl"
1873version = "1.0.69"
1874source = "registry+https://github.com/rust-lang/crates.io-index"
1875checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
1876dependencies = [
1877 "proc-macro2",
1878 "quote",
1879 "syn 2.0.119",
1675]1880]
16761881
1677[[package]]1882[[package]]
@@ -1821,6 +2026,36 @@ dependencies = [
1821]2026]
18222027
1823[[package]]2028[[package]]
2029name = "tree-sitter"
2030version = "0.23.2"
2031source = "registry+https://github.com/rust-lang/crates.io-index"
2032checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca"
2033dependencies = [
2034 "cc",
2035 "regex",
2036 "regex-syntax",
2037 "tree-sitter-language",
2038]
2039
2040[[package]]
2041name = "tree-sitter-highlight"
2042version = "0.23.2"
2043source = "registry+https://github.com/rust-lang/crates.io-index"
2044checksum = "380a7706376fa6c52ba7bf71d1e7a93856ee8ab08a7680631dfa664fdd237d66"
2045dependencies = [
2046 "lazy_static",
2047 "regex",
2048 "thiserror 1.0.69",
2049 "tree-sitter",
2050]
2051
2052[[package]]
2053name = "tree-sitter-language"
2054version = "0.1.7"
2055source = "registry+https://github.com/rust-lang/crates.io-index"
2056checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
2057
2058[[package]]
1824name = "typenum"2059name = "typenum"
1825version = "1.20.1"2060version = "1.20.1"
1826source = "registry+https://github.com/rust-lang/crates.io-index"2061source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1897,6 +2132,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1897checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"2132checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
18982133
1899[[package]]2134[[package]]
2135name = "uuid"
2136version = "1.24.0"
2137source = "registry+https://github.com/rust-lang/crates.io-index"
2138checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
2139dependencies = [
2140 "getrandom 0.4.3",
2141 "js-sys",
2142 "wasm-bindgen",
2143]
2144
2145[[package]]
2146name = "v_htmlescape"
2147version = "0.15.8"
2148source = "registry+https://github.com/rust-lang/crates.io-index"
2149checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
2150
2151[[package]]
1900name = "vcpkg"2152name = "vcpkg"
1901version = "0.2.15"2153version = "0.2.15"
1902source = "registry+https://github.com/rust-lang/crates.io-index"2154source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml +9 −0
@@ -87,6 +87,15 @@ base64 = "0.22"
87rand_core = { version = "0.6", features = ["getrandom"] }87rand_core = { version = "0.6", features = ["getrandom"] }
88# Interactive, non-echoing password entry for the CLI.88# Interactive, non-echoing password entry for the CLI.
89rpassword = "7"89rpassword = "7"
90# Glob matching for the `.gitattributes` resolver (gitignore-style patterns).
91globset = "0.4"
92# Bounded, concurrent caches (attribute rule sets, highlight output, signatures).
93moka = { 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.
96inkjet = "0.11.1"
97# Snapshot testing.
98insta = "1"
9099
91[workspace.lints.rust]100[workspace.lints.rust]
92unsafe_code = "forbid"101unsafe_code = "forbid"
crates/git/Cargo.toml +2 −0
@@ -12,6 +12,8 @@ publish.workspace = true
12[dependencies]12[dependencies]
13model = { workspace = true }13model = { workspace = true }
14git2 = { workspace = true }14git2 = { workspace = true }
15globset = { workspace = true }
16moka = { workspace = true }
15thiserror = { workspace = true }17thiserror = { workspace = true }
16# Pin the libgit2 bindings to a build that requires libgit2 <= the version18# Pin the libgit2 bindings to a build that requires libgit2 <= the version
17# nixpkgs ships (1.9.4). git2 0.20's default libgit2-sys (0.18.7, bundling 1.9.6)19# 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
26use std::collections::HashMap;
27use std::sync::Arc;
28
29use globset::{GlobBuilder, GlobMatcher};
30
31use crate::GitError;
32use crate::repo::Repo;
33use 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)]
41pub 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)]
57pub struct Attributes {
58 map: HashMap<String, AttrValue>,
59}
60
61impl 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)]
127struct 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)]
136struct 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)]
150pub struct AttributesSet {
151 files: Vec<AttrFile>,
152}
153
154impl 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).
195fn 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.
205fn 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.
235fn 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.
254fn 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
265impl 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).
307fn 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)]
322pub struct AttributesCache {
323 inner: moka::sync::Cache<String, Arc<AttributesSet>>,
324}
325
326impl 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
350impl Default for AttributesCache {
351 fn default() -> Self {
352 Self::new(256)
353 }
354}
355
356#[cfg(test)]
357mod 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 @@
27//! name, so renames and group moves are metadata-only. The database is the sole27//! name, so renames and group moves are metadata-only. The database is the sole
28//! name→path authority; [`repo_path`] is the one place that mapping is computed.28//! name→path authority; [`repo_path`] is the one place that mapping is computed.
2929
30mod attributes;
30mod repo;31mod repo;
31mod types;32mod types;
3233
@@ -37,6 +38,7 @@ use std::path::{Path, PathBuf};
3738
38use git2::{Repository, RepositoryInitOptions};39use git2::{Repository, RepositoryInitOptions};
3940
41pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet};
40pub use crate::repo::Repo;42pub use crate::repo::Repo;
41pub use crate::types::{43pub use crate::types::{
42 Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind,44 Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind,
crates/git/src/repo.rs +7 −1
@@ -473,8 +473,14 @@ impl Repo {
473 }473 }
474 }474 }
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
476 /// Look up a commit by our [`Oid`], mapping a miss to [`GitError::RevNotFound`].482 /// 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> {
478 let parsed = git2::Oid::from_str(oid.as_str())484 let parsed = git2::Oid::from_str(oid.as_str())
479 .map_err(|_| GitError::RevNotFound(oid.to_string()))?;485 .map_err(|_| GitError::RevNotFound(oid.to_string()))?;
480 self.inner486 self.inner
crates/highlight/Cargo.toml +4 −0
@@ -10,6 +10,10 @@ repository.workspace = true
10publish.workspace = true10publish.workspace = true
1111
12[dependencies]12[dependencies]
13inkjet = { workspace = true }
14
15[dev-dependencies]
16insta = { workspace = true }
1317
14[lints]18[lints]
15workspace = true19workspace = 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`.
16pub 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]
34pub 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)]
58mod 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
15use std::path::Path;
16
17use 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)]
22pub struct Lang {
23 language: Option<Language>,
24 name: Option<String>,
25}
26
27impl 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.
72const 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.
83const 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]
105pub 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`.
154fn 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`.
167fn 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.
188fn 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.
201fn 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)]
232mod 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 @@
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
44
5//! Syntax highlighting producing independently-valid HTML per line.5//! 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
21mod classes;
22mod lang;
23
24use std::panic::{AssertUnwindSafe, catch_unwind};
25
26use inkjet::Highlighter;
27use inkjet::constants::HIGHLIGHT_NAMES;
28use inkjet::tree_sitter_highlight::HighlightEvent;
29
30pub use crate::classes::{HL_CLASSES, class_for};
31pub 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).
36pub const DEFAULT_MAX_BYTES: usize = 1_048_576;
37
38/// A single line longer than this marks the file minified; it is rendered plain.
39pub const MINIFIED_LINE_BYTES: usize = 5_000;
40
41/// Bytes sniffed from the head of the content when deciding whether it is binary.
42const 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)]
48pub 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)]
57pub 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)]
64pub 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]
77pub 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]
109pub 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).
120fn 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).
133fn 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.
168fn 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.
182fn 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)]
199mod 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 @@
56 pkgs.libgit256 pkgs.libgit2
57 pkgs.zlib57 pkgs.zlib
58 pkgs.openssl58 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
59 ];63 ];
60 # Use the system libgit2 rather than the vendored copy.64 # Use the system libgit2 rather than the vendored copy.
61 LIBGIT2_NO_VENDOR = "1";65 LIBGIT2_NO_VENDOR = "1";