Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +264 −12
| @@ -3,6 +3,28 @@ | |||
| 3 | 3 | version = 4 | |
| 4 | 4 | ||
| 5 | 5 | [[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]] | |
| 6 | 28 | name = "allocator-api2" | |
| 7 | 29 | version = "0.2.21" | |
| 8 | 30 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -110,7 +132,7 @@ dependencies = [ | |||
| 110 | 132 | "serde", | |
| 111 | 133 | "serde_json", | |
| 112 | 134 | "sha2 0.10.9", | |
| 113 | - | "thiserror", | |
| 135 | + | "thiserror 2.0.19", | |
| 114 | 136 | ] | |
| 115 | 137 | ||
| 116 | 138 | [[package]] | |
| @@ -168,6 +190,16 @@ dependencies = [ | |||
| 168 | 190 | ] | |
| 169 | 191 | ||
| 170 | 192 | [[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]] | |
| 171 | 203 | name = "bumpalo" | |
| 172 | 204 | version = "3.20.3" | |
| 173 | 205 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -304,12 +336,23 @@ version = "0.1.0" | |||
| 304 | 336 | dependencies = [ | |
| 305 | 337 | "figment", | |
| 306 | 338 | "serde", | |
| 307 | - | "thiserror", | |
| 339 | + | "thiserror 2.0.19", | |
| 308 | 340 | "toml", | |
| 309 | 341 | "url", | |
| 310 | 342 | ] | |
| 311 | 343 | ||
| 312 | 344 | [[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]] | |
| 313 | 356 | name = "cpufeatures" | |
| 314 | 357 | version = "0.2.17" | |
| 315 | 358 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -343,6 +386,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 343 | 386 | checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" | |
| 344 | 387 | ||
| 345 | 388 | [[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]] | |
| 346 | 407 | name = "crossbeam-queue" | |
| 347 | 408 | version = "0.3.13" | |
| 348 | 409 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -434,6 +495,12 @@ dependencies = [ | |||
| 434 | 495 | ] | |
| 435 | 496 | ||
| 436 | 497 | [[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]] | |
| 437 | 504 | name = "equivalent" | |
| 438 | 505 | version = "1.0.2" | |
| 439 | 506 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -652,10 +719,12 @@ name = "git" | |||
| 652 | 719 | version = "0.1.0" | |
| 653 | 720 | dependencies = [ | |
| 654 | 721 | "git2", | |
| 722 | + | "globset", | |
| 655 | 723 | "libgit2-sys", | |
| 656 | 724 | "model", | |
| 725 | + | "moka", | |
| 657 | 726 | "tempfile", | |
| 658 | - | "thiserror", | |
| 727 | + | "thiserror 2.0.19", | |
| 659 | 728 | ] | |
| 660 | 729 | ||
| 661 | 730 | [[package]] | |
| @@ -672,6 +741,19 @@ dependencies = [ | |||
| 672 | 741 | ] | |
| 673 | 742 | ||
| 674 | 743 | [[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]] | |
| 675 | 757 | name = "hashbrown" | |
| 676 | 758 | version = "0.16.1" | |
| 677 | 759 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -712,6 +794,10 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" | |||
| 712 | 794 | [[package]] | |
| 713 | 795 | name = "highlight" | |
| 714 | 796 | version = "0.1.0" | |
| 797 | + | dependencies = [ | |
| 798 | + | "inkjet", | |
| 799 | + | "insta", | |
| 800 | + | ] | |
| 715 | 801 | ||
| 716 | 802 | [[package]] | |
| 717 | 803 | name = "hkdf" | |
| @@ -862,12 +948,41 @@ dependencies = [ | |||
| 862 | 948 | ] | |
| 863 | 949 | ||
| 864 | 950 | [[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]] | |
| 865 | 968 | name = "inlinable_string" | |
| 866 | 969 | version = "0.1.15" | |
| 867 | 970 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 868 | 971 | checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" | |
| 869 | 972 | ||
| 870 | 973 | [[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]] | |
| 871 | 986 | name = "is_terminal_polyfill" | |
| 872 | 987 | version = "1.70.2" | |
| 873 | 988 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -901,6 +1016,12 @@ dependencies = [ | |||
| 901 | 1016 | ] | |
| 902 | 1017 | ||
| 903 | 1018 | [[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]] | |
| 904 | 1025 | name = "libc" | |
| 905 | 1026 | version = "0.2.189" | |
| 906 | 1027 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1003,7 +1124,24 @@ dependencies = [ | |||
| 1003 | 1124 | name = "model" | |
| 1004 | 1125 | version = "0.1.0" | |
| 1005 | 1126 | 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", | |
| 1007 | 1145 | ] | |
| 1008 | 1146 | ||
| 1009 | 1147 | [[package]] | |
| @@ -1109,6 +1247,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1109 | 1247 | checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" | |
| 1110 | 1248 | ||
| 1111 | 1249 | [[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]] | |
| 1112 | 1256 | name = "potential_utf" | |
| 1113 | 1257 | version = "0.1.5" | |
| 1114 | 1258 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1234,6 +1378,35 @@ dependencies = [ | |||
| 1234 | 1378 | ] | |
| 1235 | 1379 | ||
| 1236 | 1380 | [[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]] | |
| 1237 | 1410 | name = "rpassword" | |
| 1238 | 1411 | version = "7.5.4" | |
| 1239 | 1412 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1371,6 +1544,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1371 | 1544 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | |
| 1372 | 1545 | ||
| 1373 | 1546 | [[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]] | |
| 1374 | 1553 | name = "slab" | |
| 1375 | 1554 | version = "0.4.12" | |
| 1376 | 1555 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1444,7 +1623,7 @@ dependencies = [ | |||
| 1444 | 1623 | "serde_json", | |
| 1445 | 1624 | "sha2 0.10.9", | |
| 1446 | 1625 | "smallvec", | |
| 1447 | - | "thiserror", | |
| 1626 | + | "thiserror 2.0.19", | |
| 1448 | 1627 | "tokio", | |
| 1449 | 1628 | "tokio-stream", | |
| 1450 | 1629 | "tracing", | |
| @@ -1484,7 +1663,7 @@ dependencies = [ | |||
| 1484 | 1663 | "sqlx-postgres", | |
| 1485 | 1664 | "sqlx-sqlite", | |
| 1486 | 1665 | "syn 2.0.119", | |
| 1487 | - | "thiserror", | |
| 1666 | + | "thiserror 2.0.19", | |
| 1488 | 1667 | "tokio", | |
| 1489 | 1668 | "url", | |
| 1490 | 1669 | ] | |
| @@ -1511,7 +1690,7 @@ dependencies = [ | |||
| 1511 | 1690 | "sha1", | |
| 1512 | 1691 | "sha2 0.11.0", | |
| 1513 | 1692 | "sqlx-core", | |
| 1514 | - | "thiserror", | |
| 1693 | + | "thiserror 2.0.19", | |
| 1515 | 1694 | "tracing", | |
| 1516 | 1695 | ] | |
| 1517 | 1696 | ||
| @@ -1545,7 +1724,7 @@ dependencies = [ | |||
| 1545 | 1724 | "smallvec", | |
| 1546 | 1725 | "sqlx-core", | |
| 1547 | 1726 | "stringprep", | |
| 1548 | - | "thiserror", | |
| 1727 | + | "thiserror 2.0.19", | |
| 1549 | 1728 | "tracing", | |
| 1550 | 1729 | "whoami", | |
| 1551 | 1730 | ] | |
| @@ -1569,7 +1748,7 @@ dependencies = [ | |||
| 1569 | 1748 | "percent-encoding", | |
| 1570 | 1749 | "serde", | |
| 1571 | 1750 | "sqlx-core", | |
| 1572 | - | "thiserror", | |
| 1751 | + | "thiserror 2.0.19", | |
| 1573 | 1752 | "tracing", | |
| 1574 | 1753 | "url", | |
| 1575 | 1754 | ] | |
| @@ -1591,7 +1770,7 @@ dependencies = [ | |||
| 1591 | 1770 | "model", | |
| 1592 | 1771 | "sqlx", | |
| 1593 | 1772 | "tempfile", | |
| 1594 | - | "thiserror", | |
| 1773 | + | "thiserror 2.0.19", | |
| 1595 | 1774 | "tokio", | |
| 1596 | 1775 | "ulid", | |
| 1597 | 1776 | ] | |
| @@ -1653,13 +1832,19 @@ dependencies = [ | |||
| 1653 | 1832 | ] | |
| 1654 | 1833 | ||
| 1655 | 1834 | [[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]] | |
| 1656 | 1841 | name = "tempfile" | |
| 1657 | 1842 | version = "3.27.0" | |
| 1658 | 1843 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1659 | 1844 | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | |
| 1660 | 1845 | dependencies = [ | |
| 1661 | 1846 | "fastrand", | |
| 1662 | - | "getrandom 0.3.4", | |
| 1847 | + | "getrandom 0.4.3", | |
| 1663 | 1848 | "once_cell", | |
| 1664 | 1849 | "rustix", | |
| 1665 | 1850 | "windows-sys 0.61.2", | |
| @@ -1667,11 +1852,31 @@ dependencies = [ | |||
| 1667 | 1852 | ||
| 1668 | 1853 | [[package]] | |
| 1669 | 1854 | 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" | |
| 1670 | 1864 | version = "2.0.19" | |
| 1671 | 1865 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1672 | 1866 | checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" | |
| 1673 | 1867 | 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", | |
| 1675 | 1880 | ] | |
| 1676 | 1881 | ||
| 1677 | 1882 | [[package]] | |
| @@ -1821,6 +2026,36 @@ dependencies = [ | |||
| 1821 | 2026 | ] | |
| 1822 | 2027 | ||
| 1823 | 2028 | [[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]] | |
| 1824 | 2059 | name = "typenum" | |
| 1825 | 2060 | version = "1.20.1" | |
| 1826 | 2061 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1897,6 +2132,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1897 | 2132 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" | |
| 1898 | 2133 | ||
| 1899 | 2134 | [[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]] | |
| 1900 | 2152 | name = "vcpkg" | |
| 1901 | 2153 | version = "0.2.15" | |
| 1902 | 2154 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
Cargo.toml +9 −0
| @@ -87,6 +87,15 @@ base64 = "0.22" | |||
| 87 | 87 | rand_core = { version = "0.6", features = ["getrandom"] } | |
| 88 | 88 | # Interactive, non-echoing password entry for the CLI. | |
| 89 | 89 | 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" | |
| 90 | 99 | ||
| 91 | 100 | [workspace.lints.rust] | |
| 92 | 101 | unsafe_code = "forbid" | |
crates/git/Cargo.toml +2 −0
| @@ -12,6 +12,8 @@ publish.workspace = true | |||
| 12 | 12 | [dependencies] | |
| 13 | 13 | model = { workspace = true } | |
| 14 | 14 | git2 = { workspace = true } | |
| 15 | + | globset = { workspace = true } | |
| 16 | + | moka = { workspace = true } | |
| 15 | 17 | thiserror = { workspace = true } | |
| 16 | 18 | # Pin the libgit2 bindings to a build that requires libgit2 <= the version | |
| 17 | 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 | + | ||
| 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 @@ | |||
| 27 | 27 | //! name, so renames and group moves are metadata-only. The database is the sole | |
| 28 | 28 | //! name→path authority; [`repo_path`] is the one place that mapping is computed. | |
| 29 | 29 | ||
| 30 | + | mod attributes; | |
| 30 | 31 | mod repo; | |
| 31 | 32 | mod types; | |
| 32 | 33 | ||
| @@ -37,6 +38,7 @@ use std::path::{Path, PathBuf}; | |||
| 37 | 38 | ||
| 38 | 39 | use git2::{Repository, RepositoryInitOptions}; | |
| 39 | 40 | ||
| 41 | + | pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet}; | |
| 40 | 42 | pub use crate::repo::Repo; | |
| 41 | 43 | pub use crate::types::{ | |
| 42 | 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 | } | |
| 475 | 475 | ||
| 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 | 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 | 484 | let parsed = git2::Oid::from_str(oid.as_str()) | |
| 479 | 485 | .map_err(|_| GitError::RevNotFound(oid.to_string()))?; | |
| 480 | 486 | self.inner | |
crates/highlight/Cargo.toml +4 −0
| @@ -10,6 +10,10 @@ repository.workspace = true | |||
| 10 | 10 | publish.workspace = true | |
| 11 | 11 | ||
| 12 | 12 | [dependencies] | |
| 13 | + | inkjet = { workspace = true } | |
| 14 | + | ||
| 15 | + | [dev-dependencies] | |
| 16 | + | insta = { workspace = true } | |
| 13 | 17 | ||
| 14 | 18 | [lints] | |
| 15 | 19 | 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 @@ | |||
| 3 | 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | 4 | ||
| 5 | 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 | + | ||
| 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 @@ | |||
| 56 | 56 | pkgs.libgit2 | |
| 57 | 57 | pkgs.zlib | |
| 58 | 58 | 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 | 64 | # Use the system libgit2 rather than the vendored copy. | |
| 61 | 65 | LIBGIT2_NO_VENDOR = "1"; | |