Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +264 −12
| @@ -3,6 +3,28 @@ | |||
| 3 | version = 4 | 3 | version = 4 |
| 4 | 4 | ||
| 5 | [[package]] | 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 | name = "allocator-api2" | 28 | name = "allocator-api2" |
| 7 | version = "0.2.21" | 29 | version = "0.2.21" |
| 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | 30 | source = "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 | ] |
| 115 | 137 | ||
| 116 | [[package]] | 138 | [[package]] |
| @@ -168,6 +190,16 @@ dependencies = [ | |||
| 168 | ] | 190 | ] |
| 169 | 191 | ||
| 170 | [[package]] | 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 | name = "bumpalo" | 203 | name = "bumpalo" |
| 172 | version = "3.20.3" | 204 | version = "3.20.3" |
| 173 | source = "registry+https://github.com/rust-lang/crates.io-index" | 205 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -304,12 +336,23 @@ version = "0.1.0" | |||
| 304 | dependencies = [ | 336 | dependencies = [ |
| 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 | ] |
| 311 | 343 | ||
| 312 | [[package]] | 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 | name = "cpufeatures" | 356 | name = "cpufeatures" |
| 314 | version = "0.2.17" | 357 | version = "0.2.17" |
| 315 | source = "registry+https://github.com/rust-lang/crates.io-index" | 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 | checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" | 386 | checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" |
| 344 | 387 | ||
| 345 | [[package]] | 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 | name = "crossbeam-queue" | 407 | name = "crossbeam-queue" |
| 347 | version = "0.3.13" | 408 | version = "0.3.13" |
| 348 | source = "registry+https://github.com/rust-lang/crates.io-index" | 409 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -434,6 +495,12 @@ dependencies = [ | |||
| 434 | ] | 495 | ] |
| 435 | 496 | ||
| 436 | [[package]] | 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 | name = "equivalent" | 504 | name = "equivalent" |
| 438 | version = "1.0.2" | 505 | version = "1.0.2" |
| 439 | source = "registry+https://github.com/rust-lang/crates.io-index" | 506 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -652,10 +719,12 @@ name = "git" | |||
| 652 | version = "0.1.0" | 719 | version = "0.1.0" |
| 653 | dependencies = [ | 720 | dependencies = [ |
| 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 | ] |
| 660 | 729 | ||
| 661 | [[package]] | 730 | [[package]] |
| @@ -672,6 +741,19 @@ dependencies = [ | |||
| 672 | ] | 741 | ] |
| 673 | 742 | ||
| 674 | [[package]] | 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 | name = "hashbrown" | 757 | name = "hashbrown" |
| 676 | version = "0.16.1" | 758 | version = "0.16.1" |
| 677 | source = "registry+https://github.com/rust-lang/crates.io-index" | 759 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -712,6 +794,10 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" | |||
| 712 | [[package]] | 794 | [[package]] |
| 713 | name = "highlight" | 795 | name = "highlight" |
| 714 | version = "0.1.0" | 796 | version = "0.1.0" |
| 797 | dependencies = [ | ||
| 798 | "inkjet", | ||
| 799 | "insta", | ||
| 800 | ] | ||
| 715 | 801 | ||
| 716 | [[package]] | 802 | [[package]] |
| 717 | name = "hkdf" | 803 | name = "hkdf" |
| @@ -862,12 +948,41 @@ dependencies = [ | |||
| 862 | ] | 948 | ] |
| 863 | 949 | ||
| 864 | [[package]] | 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 | name = "inlinable_string" | 968 | name = "inlinable_string" |
| 866 | version = "0.1.15" | 969 | version = "0.1.15" |
| 867 | source = "registry+https://github.com/rust-lang/crates.io-index" | 970 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 868 | checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" | 971 | checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" |
| 869 | 972 | ||
| 870 | [[package]] | 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 | name = "is_terminal_polyfill" | 986 | name = "is_terminal_polyfill" |
| 872 | version = "1.70.2" | 987 | version = "1.70.2" |
| 873 | source = "registry+https://github.com/rust-lang/crates.io-index" | 988 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -901,6 +1016,12 @@ dependencies = [ | |||
| 901 | ] | 1016 | ] |
| 902 | 1017 | ||
| 903 | [[package]] | 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 | name = "libc" | 1025 | name = "libc" |
| 905 | version = "0.2.189" | 1026 | version = "0.2.189" |
| 906 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1027 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1003,7 +1124,24 @@ dependencies = [ | |||
| 1003 | name = "model" | 1124 | name = "model" |
| 1004 | version = "0.1.0" | 1125 | version = "0.1.0" |
| 1005 | dependencies = [ | 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 | [[package]] | 1147 | [[package]] |
| @@ -1109,6 +1247,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1109 | checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" | 1247 | checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" |
| 1110 | 1248 | ||
| 1111 | [[package]] | 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 | name = "potential_utf" | 1256 | name = "potential_utf" |
| 1113 | version = "0.1.5" | 1257 | version = "0.1.5" |
| 1114 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1258 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1234,6 +1378,35 @@ dependencies = [ | |||
| 1234 | ] | 1378 | ] |
| 1235 | 1379 | ||
| 1236 | [[package]] | 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 | name = "rpassword" | 1410 | name = "rpassword" |
| 1238 | version = "7.5.4" | 1411 | version = "7.5.4" |
| 1239 | source = "registry+https://github.com/rust-lang/crates.io-index" | 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 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | 1544 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" |
| 1372 | 1545 | ||
| 1373 | [[package]] | 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 | name = "slab" | 1553 | name = "slab" |
| 1375 | version = "0.4.12" | 1554 | version = "0.4.12" |
| 1376 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1555 | source = "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 | ] |
| 1517 | 1696 | ||
| @@ -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 | ] |
| 1654 | 1833 | ||
| 1655 | [[package]] | 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 | name = "tempfile" | 1841 | name = "tempfile" |
| 1657 | version = "3.27.0" | 1842 | version = "3.27.0" |
| 1658 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1843 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1659 | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | 1844 | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" |
| 1660 | dependencies = [ | 1845 | dependencies = [ |
| 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 = [ | |||
| 1667 | 1852 | ||
| 1668 | [[package]] | 1853 | [[package]] |
| 1669 | name = "thiserror" | 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 | version = "2.0.19" | 1864 | version = "2.0.19" |
| 1671 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1865 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1672 | checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" | 1866 | checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" |
| 1673 | dependencies = [ | 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 | [[package]] | 1882 | [[package]] |
| @@ -1821,6 +2026,36 @@ dependencies = [ | |||
| 1821 | ] | 2026 | ] |
| 1822 | 2027 | ||
| 1823 | [[package]] | 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 | name = "typenum" | 2059 | name = "typenum" |
| 1825 | version = "1.20.1" | 2060 | version = "1.20.1" |
| 1826 | source = "registry+https://github.com/rust-lang/crates.io-index" | 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 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" | 2132 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" |
| 1898 | 2133 | ||
| 1899 | [[package]] | 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 | name = "vcpkg" | 2152 | name = "vcpkg" |
| 1901 | version = "0.2.15" | 2153 | version = "0.2.15" |
| 1902 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2154 | source = "registry+https://github.com/rust-lang/crates.io-index" |
Cargo.toml +9 −0
| @@ -87,6 +87,15 @@ base64 = "0.22" | |||
| 87 | rand_core = { version = "0.6", features = ["getrandom"] } | 87 | rand_core = { version = "0.6", features = ["getrandom"] } |
| 88 | # Interactive, non-echoing password entry for the CLI. | 88 | # Interactive, non-echoing password entry for the CLI. |
| 89 | rpassword = "7" | 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 | [workspace.lints.rust] | 100 | [workspace.lints.rust] |
| 92 | unsafe_code = "forbid" | 101 | unsafe_code = "forbid" |
crates/git/Cargo.toml +2 −0
| @@ -12,6 +12,8 @@ publish.workspace = true | |||
| 12 | [dependencies] | 12 | [dependencies] |
| 13 | model = { workspace = true } | 13 | model = { workspace = true } |
| 14 | git2 = { workspace = true } | 14 | git2 = { workspace = true } |
| 15 | globset = { workspace = true } | ||
| 16 | moka = { workspace = true } | ||
| 15 | thiserror = { workspace = true } | 17 | thiserror = { workspace = true } |
| 16 | # Pin the libgit2 bindings to a build that requires libgit2 <= the version | 18 | # 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 | |||
| 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 | //! name, so renames and group moves are metadata-only. The database is the sole | 27 | //! 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. |
| 29 | 29 | ||
| 30 | mod attributes; | ||
| 30 | mod repo; | 31 | mod repo; |
| 31 | mod types; | 32 | mod types; |
| 32 | 33 | ||
| @@ -37,6 +38,7 @@ use std::path::{Path, PathBuf}; | |||
| 37 | 38 | ||
| 38 | use git2::{Repository, RepositoryInitOptions}; | 39 | use git2::{Repository, RepositoryInitOptions}; |
| 39 | 40 | ||
| 41 | pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet}; | ||
| 40 | pub use crate::repo::Repo; | 42 | pub use crate::repo::Repo; |
| 41 | pub use crate::types::{ | 43 | pub 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 | } |
| 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 | /// 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.inner | 486 | self.inner |
crates/highlight/Cargo.toml +4 −0
| @@ -10,6 +10,10 @@ repository.workspace = true | |||
| 10 | publish.workspace = true | 10 | publish.workspace = true |
| 11 | 11 | ||
| 12 | [dependencies] | 12 | [dependencies] |
| 13 | inkjet = { workspace = true } | ||
| 14 | |||
| 15 | [dev-dependencies] | ||
| 16 | insta = { workspace = true } | ||
| 13 | 17 | ||
| 14 | [lints] | 18 | [lints] |
| 15 | workspace = true | 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 | // 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/. |
| 4 | 4 | ||
| 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 | |||
| 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 | pkgs.libgit2 | 56 | pkgs.libgit2 |
| 57 | pkgs.zlib | 57 | pkgs.zlib |
| 58 | pkgs.openssl | 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 | # 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"; |