A human-friendly DSL for ATProto Lexicons
27
fork

Configure Feed

Select the types of activity you want to include in your feed.

Extract mlf-atproto plumbing crate

Split protocol-level primitives (DID/NSID/DNS identity resolution,
generic XRPC HTTP client, session management, record CRUD wrappers,
client-side DAG-CBOR CID computation) out of mlf-lexicon-fetcher into
a new mlf-atproto crate. The fetcher is refactored to consume it; its
public API is preserved. This is the plumbing foundation for
mlf-publish, mlf-plugin-host, and the DNS provider plugin binaries.

authored by stavola.xyz and committed by

Tangled bca36461 be620bfd

+4438 -1704
+507 -17
Cargo.lock
··· 129 129 ] 130 130 131 131 [[package]] 132 + name = "arrayref" 133 + version = "0.3.9" 134 + source = "registry+https://github.com/rust-lang/crates.io-index" 135 + checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" 136 + 137 + [[package]] 138 + name = "arrayvec" 139 + version = "0.7.6" 140 + source = "registry+https://github.com/rust-lang/crates.io-index" 141 + checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" 142 + 143 + [[package]] 144 + name = "assert-json-diff" 145 + version = "2.0.2" 146 + source = "registry+https://github.com/rust-lang/crates.io-index" 147 + checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" 148 + dependencies = [ 149 + "serde", 150 + "serde_json", 151 + ] 152 + 153 + [[package]] 132 154 name = "async-trait" 133 155 version = "0.1.89" 134 156 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 187 209 ] 188 210 189 211 [[package]] 212 + name = "base-x" 213 + version = "0.2.11" 214 + source = "registry+https://github.com/rust-lang/crates.io-index" 215 + checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" 216 + 217 + [[package]] 218 + name = "base256emoji" 219 + version = "1.0.2" 220 + source = "registry+https://github.com/rust-lang/crates.io-index" 221 + checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" 222 + dependencies = [ 223 + "const-str", 224 + "match-lookup", 225 + ] 226 + 227 + [[package]] 190 228 name = "base64" 191 229 version = "0.22.1" 192 230 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 220 258 checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" 221 259 222 260 [[package]] 261 + name = "blake2b_simd" 262 + version = "1.0.4" 263 + source = "registry+https://github.com/rust-lang/crates.io-index" 264 + checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" 265 + dependencies = [ 266 + "arrayref", 267 + "arrayvec", 268 + "constant_time_eq", 269 + ] 270 + 271 + [[package]] 272 + name = "blake2s_simd" 273 + version = "1.0.4" 274 + source = "registry+https://github.com/rust-lang/crates.io-index" 275 + checksum = "ee29928bad1e3f94c9d1528da29e07a1d3d04817ae8332de1e8b846c8439f4b3" 276 + dependencies = [ 277 + "arrayref", 278 + "arrayvec", 279 + "constant_time_eq", 280 + ] 281 + 282 + [[package]] 283 + name = "blake3" 284 + version = "1.8.4" 285 + source = "registry+https://github.com/rust-lang/crates.io-index" 286 + checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" 287 + dependencies = [ 288 + "arrayref", 289 + "arrayvec", 290 + "cc", 291 + "cfg-if", 292 + "constant_time_eq", 293 + "cpufeatures 0.3.0", 294 + ] 295 + 296 + [[package]] 223 297 name = "block-buffer" 224 298 version = "0.10.4" 225 299 source = "registry+https://github.com/rust-lang/crates.io-index" 226 300 checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 227 301 dependencies = [ 228 302 "generic-array", 303 + ] 304 + 305 + [[package]] 306 + name = "block-buffer" 307 + version = "0.12.0" 308 + source = "registry+https://github.com/rust-lang/crates.io-index" 309 + checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" 310 + dependencies = [ 311 + "hybrid-array", 229 312 ] 230 313 231 314 [[package]] ··· 271 354 checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" 272 355 273 356 [[package]] 357 + name = "cbor4ii" 358 + version = "0.2.14" 359 + source = "registry+https://github.com/rust-lang/crates.io-index" 360 + checksum = "b544cf8c89359205f4f990d0e6f3828db42df85b5dac95d09157a250eb0749c4" 361 + dependencies = [ 362 + "serde", 363 + ] 364 + 365 + [[package]] 274 366 name = "cc" 275 367 version = "1.2.60" 276 368 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 337 429 ] 338 430 339 431 [[package]] 432 + name = "cid" 433 + version = "0.11.2" 434 + source = "registry+https://github.com/rust-lang/crates.io-index" 435 + checksum = "cbb4913a732503de004e94ce7a4e7119ffc55d1727cc9979ac3b52f511e6578c" 436 + dependencies = [ 437 + "multibase", 438 + "multihash", 439 + "no_std_io2", 440 + "serde", 441 + "serde_bytes", 442 + "unsigned-varint", 443 + ] 444 + 445 + [[package]] 340 446 name = "clap" 341 447 version = "4.5.48" 342 448 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 394 500 ] 395 501 396 502 [[package]] 503 + name = "const-str" 504 + version = "0.4.3" 505 + source = "registry+https://github.com/rust-lang/crates.io-index" 506 + checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 507 + 508 + [[package]] 509 + name = "constant_time_eq" 510 + version = "0.4.2" 511 + source = "registry+https://github.com/rust-lang/crates.io-index" 512 + checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" 513 + 514 + [[package]] 397 515 name = "core-foundation" 398 516 version = "0.9.4" 399 517 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 419 537 ] 420 538 421 539 [[package]] 540 + name = "cpufeatures" 541 + version = "0.3.0" 542 + source = "registry+https://github.com/rust-lang/crates.io-index" 543 + checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" 544 + dependencies = [ 545 + "libc", 546 + ] 547 + 548 + [[package]] 422 549 name = "crunchy" 423 550 version = "0.2.4" 424 551 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 435 562 ] 436 563 437 564 [[package]] 565 + name = "crypto-common" 566 + version = "0.2.1" 567 + source = "registry+https://github.com/rust-lang/crates.io-index" 568 + checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" 569 + dependencies = [ 570 + "hybrid-array", 571 + ] 572 + 573 + [[package]] 438 574 name = "dashmap" 439 575 version = "5.5.3" 440 576 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 454 590 checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" 455 591 456 592 [[package]] 593 + name = "data-encoding-macro" 594 + version = "0.1.18" 595 + source = "registry+https://github.com/rust-lang/crates.io-index" 596 + checksum = "47ce6c96ea0102f01122a185683611bd5ac8d99e62bc59dd12e6bda344ee673d" 597 + dependencies = [ 598 + "data-encoding", 599 + "data-encoding-macro-internal", 600 + ] 601 + 602 + [[package]] 603 + name = "data-encoding-macro-internal" 604 + version = "0.1.16" 605 + source = "registry+https://github.com/rust-lang/crates.io-index" 606 + checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" 607 + dependencies = [ 608 + "data-encoding", 609 + "syn 2.0.106", 610 + ] 611 + 612 + [[package]] 457 613 name = "datatest-stable" 458 614 version = "0.3.3" 459 615 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 466 622 ] 467 623 468 624 [[package]] 625 + name = "deadpool" 626 + version = "0.12.3" 627 + source = "registry+https://github.com/rust-lang/crates.io-index" 628 + checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" 629 + dependencies = [ 630 + "deadpool-runtime", 631 + "lazy_static", 632 + "num_cpus", 633 + "tokio", 634 + ] 635 + 636 + [[package]] 637 + name = "deadpool-runtime" 638 + version = "0.1.4" 639 + source = "registry+https://github.com/rust-lang/crates.io-index" 640 + checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" 641 + 642 + [[package]] 469 643 name = "deranged" 470 644 version = "0.5.4" 471 645 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 480 654 source = "registry+https://github.com/rust-lang/crates.io-index" 481 655 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 482 656 dependencies = [ 483 - "block-buffer", 484 - "crypto-common", 657 + "block-buffer 0.10.4", 658 + "crypto-common 0.1.6", 659 + ] 660 + 661 + [[package]] 662 + name = "digest" 663 + version = "0.11.2" 664 + source = "registry+https://github.com/rust-lang/crates.io-index" 665 + checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" 666 + dependencies = [ 667 + "block-buffer 0.12.0", 668 + "crypto-common 0.2.1", 485 669 ] 486 670 487 671 [[package]] ··· 626 810 dependencies = [ 627 811 "futures-channel", 628 812 "futures-core", 813 + "futures-executor", 629 814 "futures-io", 630 815 "futures-sink", 631 816 "futures-task", ··· 649 834 checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 650 835 651 836 [[package]] 837 + name = "futures-executor" 838 + version = "0.3.31" 839 + source = "registry+https://github.com/rust-lang/crates.io-index" 840 + checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" 841 + dependencies = [ 842 + "futures-core", 843 + "futures-task", 844 + "futures-util", 845 + ] 846 + 847 + [[package]] 652 848 name = "futures-io" 653 849 version = "0.3.31" 654 850 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 777 973 778 974 [[package]] 779 975 name = "hashbrown" 780 - version = "0.16.0" 976 + version = "0.17.0" 781 977 source = "registry+https://github.com/rust-lang/crates.io-index" 782 - checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" 978 + checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" 783 979 784 980 [[package]] 785 981 name = "heck" 786 982 version = "0.5.0" 787 983 source = "registry+https://github.com/rust-lang/crates.io-index" 788 984 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 985 + 986 + [[package]] 987 + name = "hermit-abi" 988 + version = "0.5.2" 989 + source = "registry+https://github.com/rust-lang/crates.io-index" 990 + checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" 789 991 790 992 [[package]] 791 993 name = "hex_fmt" ··· 879 1081 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 880 1082 881 1083 [[package]] 1084 + name = "httpdate" 1085 + version = "1.0.3" 1086 + source = "registry+https://github.com/rust-lang/crates.io-index" 1087 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 1088 + 1089 + [[package]] 1090 + name = "hybrid-array" 1091 + version = "0.4.10" 1092 + source = "registry+https://github.com/rust-lang/crates.io-index" 1093 + checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" 1094 + dependencies = [ 1095 + "typenum", 1096 + ] 1097 + 1098 + [[package]] 882 1099 name = "hyper" 883 1100 version = "1.7.0" 884 1101 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 892 1109 "http", 893 1110 "http-body", 894 1111 "httparse", 1112 + "httpdate", 895 1113 "itoa", 896 1114 "pin-project-lite", 897 1115 "pin-utils", ··· 1110 1328 1111 1329 [[package]] 1112 1330 name = "indexmap" 1113 - version = "2.11.4" 1331 + version = "2.14.0" 1114 1332 source = "registry+https://github.com/rust-lang/crates.io-index" 1115 - checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" 1333 + checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" 1116 1334 dependencies = [ 1117 1335 "equivalent", 1118 - "hashbrown 0.16.0", 1336 + "hashbrown 0.17.0", 1119 1337 ] 1120 1338 1121 1339 [[package]] ··· 1170 1388 ] 1171 1389 1172 1390 [[package]] 1391 + name = "ipld-core" 1392 + version = "0.4.3" 1393 + source = "registry+https://github.com/rust-lang/crates.io-index" 1394 + checksum = "090f624976d72f0b0bb71b86d58dc16c15e069193067cb3a3a09d655246cbbda" 1395 + dependencies = [ 1396 + "cid", 1397 + "serde", 1398 + "serde_bytes", 1399 + ] 1400 + 1401 + [[package]] 1173 1402 name = "ipnet" 1174 1403 version = "2.11.0" 1175 1404 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1214 1443 ] 1215 1444 1216 1445 [[package]] 1446 + name = "keccak" 1447 + version = "0.2.0" 1448 + source = "registry+https://github.com/rust-lang/crates.io-index" 1449 + checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" 1450 + dependencies = [ 1451 + "cfg-if", 1452 + "cpufeatures 0.3.0", 1453 + ] 1454 + 1455 + [[package]] 1217 1456 name = "langtag" 1218 1457 version = "0.4.0" 1219 1458 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1313 1552 ] 1314 1553 1315 1554 [[package]] 1555 + name = "match-lookup" 1556 + version = "0.1.2" 1557 + source = "registry+https://github.com/rust-lang/crates.io-index" 1558 + checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" 1559 + dependencies = [ 1560 + "proc-macro2", 1561 + "quote", 1562 + "syn 2.0.106", 1563 + ] 1564 + 1565 + [[package]] 1316 1566 name = "matchers" 1317 1567 version = "0.2.0" 1318 1568 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1400 1650 ] 1401 1651 1402 1652 [[package]] 1653 + name = "mlf-atproto" 1654 + version = "0.1.0" 1655 + dependencies = [ 1656 + "async-trait", 1657 + "cid", 1658 + "hickory-resolver", 1659 + "multihash-codetable", 1660 + "reqwest", 1661 + "serde", 1662 + "serde_ipld_dagcbor", 1663 + "serde_json", 1664 + "thiserror 2.0.17", 1665 + "tokio", 1666 + "wiremock", 1667 + ] 1668 + 1669 + [[package]] 1403 1670 name = "mlf-cli" 1404 1671 version = "0.1.0" 1405 1672 dependencies = [ ··· 1418 1685 "reqwest", 1419 1686 "serde", 1420 1687 "serde_json", 1421 - "sha2", 1688 + "sha2 0.10.9", 1422 1689 "thiserror 2.0.17", 1423 1690 "tokio", 1424 1691 "toml", ··· 1504 1771 version = "0.1.0" 1505 1772 dependencies = [ 1506 1773 "async-trait", 1507 - "hickory-resolver", 1774 + "mlf-atproto", 1508 1775 "reqwest", 1509 - "serde", 1510 1776 "serde_json", 1511 1777 "thiserror 2.0.17", 1512 1778 "tokio", ··· 1569 1835 ] 1570 1836 1571 1837 [[package]] 1838 + name = "multibase" 1839 + version = "0.9.2" 1840 + source = "registry+https://github.com/rust-lang/crates.io-index" 1841 + checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" 1842 + dependencies = [ 1843 + "base-x", 1844 + "base256emoji", 1845 + "data-encoding", 1846 + "data-encoding-macro", 1847 + ] 1848 + 1849 + [[package]] 1850 + name = "multihash" 1851 + version = "0.19.4" 1852 + source = "registry+https://github.com/rust-lang/crates.io-index" 1853 + checksum = "89ace881e3f514092ce9efbcb8f413d0ad9763860b828981c2de51ddc666936c" 1854 + dependencies = [ 1855 + "no_std_io2", 1856 + "serde", 1857 + "unsigned-varint", 1858 + ] 1859 + 1860 + [[package]] 1861 + name = "multihash-codetable" 1862 + version = "0.2.1" 1863 + source = "registry+https://github.com/rust-lang/crates.io-index" 1864 + checksum = "facfe64780489b29aae20d32d0245219f4a8167f91193f7061589f5dae9ba307" 1865 + dependencies = [ 1866 + "blake2b_simd", 1867 + "blake2s_simd", 1868 + "blake3", 1869 + "digest 0.11.2", 1870 + "multihash-derive", 1871 + "no_std_io2", 1872 + "ripemd", 1873 + "sha1", 1874 + "sha2 0.11.0", 1875 + "sha3", 1876 + ] 1877 + 1878 + [[package]] 1879 + name = "multihash-derive" 1880 + version = "0.9.2" 1881 + source = "registry+https://github.com/rust-lang/crates.io-index" 1882 + checksum = "0576e09c49157d1910e522e595d2b32749b029dd0bc10ff6967d588490c30348" 1883 + dependencies = [ 1884 + "multihash", 1885 + "multihash-derive-impl", 1886 + "no_std_io2", 1887 + ] 1888 + 1889 + [[package]] 1890 + name = "multihash-derive-impl" 1891 + version = "0.1.2" 1892 + source = "registry+https://github.com/rust-lang/crates.io-index" 1893 + checksum = "e3dc7141bd06405929948754f0628d247f5ca1865be745099205e5086da957cb" 1894 + dependencies = [ 1895 + "proc-macro-crate", 1896 + "proc-macro2", 1897 + "quote", 1898 + "syn 2.0.106", 1899 + "synstructure", 1900 + ] 1901 + 1902 + [[package]] 1572 1903 name = "native-tls" 1573 1904 version = "0.2.14" 1574 1905 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1583 1914 "security-framework", 1584 1915 "security-framework-sys", 1585 1916 "tempfile", 1917 + ] 1918 + 1919 + [[package]] 1920 + name = "no_std_io2" 1921 + version = "0.8.1" 1922 + source = "registry+https://github.com/rust-lang/crates.io-index" 1923 + checksum = "8a3564ce7035b1e4778d8cb6cacebb5d766b5e8fe5a75b9e441e33fb61a872c6" 1924 + dependencies = [ 1925 + "memchr", 1586 1926 ] 1587 1927 1588 1928 [[package]] ··· 1629 1969 ] 1630 1970 1631 1971 [[package]] 1972 + name = "num_cpus" 1973 + version = "1.17.0" 1974 + source = "registry+https://github.com/rust-lang/crates.io-index" 1975 + checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" 1976 + dependencies = [ 1977 + "hermit-abi", 1978 + "libc", 1979 + ] 1980 + 1981 + [[package]] 1632 1982 name = "object" 1633 1983 version = "0.37.3" 1634 1984 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1797 2147 ] 1798 2148 1799 2149 [[package]] 2150 + name = "proc-macro-crate" 2151 + version = "3.5.0" 2152 + source = "registry+https://github.com/rust-lang/crates.io-index" 2153 + checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" 2154 + dependencies = [ 2155 + "toml_edit 0.25.11+spec-1.1.0", 2156 + ] 2157 + 2158 + [[package]] 1800 2159 name = "proc-macro-error" 1801 2160 version = "1.0.4" 1802 2161 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1990 2349 ] 1991 2350 1992 2351 [[package]] 2352 + name = "ripemd" 2353 + version = "0.2.0" 2354 + source = "registry+https://github.com/rust-lang/crates.io-index" 2355 + checksum = "4dd4211456b4172d7e44261920c25acf07367c4f04bb5f5d54fc21b090d9b159" 2356 + dependencies = [ 2357 + "digest 0.11.2", 2358 + ] 2359 + 2360 + [[package]] 1993 2361 name = "rustc-demangle" 1994 2362 version = "0.1.26" 1995 2363 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2122 2490 ] 2123 2491 2124 2492 [[package]] 2493 + name = "serde_bytes" 2494 + version = "0.11.19" 2495 + source = "registry+https://github.com/rust-lang/crates.io-index" 2496 + checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" 2497 + dependencies = [ 2498 + "serde", 2499 + "serde_core", 2500 + ] 2501 + 2502 + [[package]] 2125 2503 name = "serde_core" 2126 2504 version = "1.0.228" 2127 2505 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2139 2517 "proc-macro2", 2140 2518 "quote", 2141 2519 "syn 2.0.106", 2520 + ] 2521 + 2522 + [[package]] 2523 + name = "serde_ipld_dagcbor" 2524 + version = "0.6.4" 2525 + source = "registry+https://github.com/rust-lang/crates.io-index" 2526 + checksum = "46182f4f08349a02b45c998ba3215d3f9de826246ba02bb9dddfe9a2a2100778" 2527 + dependencies = [ 2528 + "cbor4ii", 2529 + "ipld-core", 2530 + "scopeguard", 2531 + "serde", 2142 2532 ] 2143 2533 2144 2534 [[package]] ··· 2185 2575 "itoa", 2186 2576 "ryu", 2187 2577 "serde", 2578 + ] 2579 + 2580 + [[package]] 2581 + name = "sha1" 2582 + version = "0.11.0" 2583 + source = "registry+https://github.com/rust-lang/crates.io-index" 2584 + checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" 2585 + dependencies = [ 2586 + "cfg-if", 2587 + "cpufeatures 0.3.0", 2588 + "digest 0.11.2", 2188 2589 ] 2189 2590 2190 2591 [[package]] ··· 2194 2595 checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" 2195 2596 dependencies = [ 2196 2597 "cfg-if", 2197 - "cpufeatures", 2198 - "digest", 2598 + "cpufeatures 0.2.17", 2599 + "digest 0.10.7", 2600 + ] 2601 + 2602 + [[package]] 2603 + name = "sha2" 2604 + version = "0.11.0" 2605 + source = "registry+https://github.com/rust-lang/crates.io-index" 2606 + checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" 2607 + dependencies = [ 2608 + "cfg-if", 2609 + "cpufeatures 0.3.0", 2610 + "digest 0.11.2", 2611 + ] 2612 + 2613 + [[package]] 2614 + name = "sha3" 2615 + version = "0.11.0" 2616 + source = "registry+https://github.com/rust-lang/crates.io-index" 2617 + checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" 2618 + dependencies = [ 2619 + "digest 0.11.2", 2620 + "keccak", 2199 2621 ] 2200 2622 2201 2623 [[package]] ··· 2281 2703 "proc-macro2", 2282 2704 "quote", 2283 2705 "serde", 2284 - "sha2", 2706 + "sha2 0.10.9", 2285 2707 "syn 2.0.106", 2286 2708 "thiserror 1.0.69", 2287 2709 ] ··· 2596 3018 dependencies = [ 2597 3019 "serde", 2598 3020 "serde_spanned", 2599 - "toml_datetime", 2600 - "toml_edit", 3021 + "toml_datetime 0.6.11", 3022 + "toml_edit 0.22.27", 2601 3023 ] 2602 3024 2603 3025 [[package]] ··· 2610 3032 ] 2611 3033 2612 3034 [[package]] 3035 + name = "toml_datetime" 3036 + version = "1.1.1+spec-1.1.0" 3037 + source = "registry+https://github.com/rust-lang/crates.io-index" 3038 + checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" 3039 + dependencies = [ 3040 + "serde_core", 3041 + ] 3042 + 3043 + [[package]] 2613 3044 name = "toml_edit" 2614 3045 version = "0.22.27" 2615 3046 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2618 3049 "indexmap", 2619 3050 "serde", 2620 3051 "serde_spanned", 2621 - "toml_datetime", 3052 + "toml_datetime 0.6.11", 2622 3053 "toml_write", 2623 - "winnow", 3054 + "winnow 0.7.13", 3055 + ] 3056 + 3057 + [[package]] 3058 + name = "toml_edit" 3059 + version = "0.25.11+spec-1.1.0" 3060 + source = "registry+https://github.com/rust-lang/crates.io-index" 3061 + checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" 3062 + dependencies = [ 3063 + "indexmap", 3064 + "toml_datetime 1.1.1+spec-1.1.0", 3065 + "toml_parser", 3066 + "winnow 1.0.1", 3067 + ] 3068 + 3069 + [[package]] 3070 + name = "toml_parser" 3071 + version = "1.1.2+spec-1.1.0" 3072 + source = "registry+https://github.com/rust-lang/crates.io-index" 3073 + checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" 3074 + dependencies = [ 3075 + "winnow 1.0.1", 2624 3076 ] 2625 3077 2626 3078 [[package]] ··· 2853 3305 version = "0.2.1" 2854 3306 source = "registry+https://github.com/rust-lang/crates.io-index" 2855 3307 checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" 3308 + 3309 + [[package]] 3310 + name = "unsigned-varint" 3311 + version = "0.8.0" 3312 + source = "registry+https://github.com/rust-lang/crates.io-index" 3313 + checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" 2856 3314 2857 3315 [[package]] 2858 3316 name = "untrusted" ··· 3401 3859 ] 3402 3860 3403 3861 [[package]] 3862 + name = "winnow" 3863 + version = "1.0.1" 3864 + source = "registry+https://github.com/rust-lang/crates.io-index" 3865 + checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" 3866 + dependencies = [ 3867 + "memchr", 3868 + ] 3869 + 3870 + [[package]] 3404 3871 name = "winreg" 3405 3872 version = "0.50.0" 3406 3873 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3408 3875 dependencies = [ 3409 3876 "cfg-if", 3410 3877 "windows-sys 0.48.0", 3878 + ] 3879 + 3880 + [[package]] 3881 + name = "wiremock" 3882 + version = "0.6.5" 3883 + source = "registry+https://github.com/rust-lang/crates.io-index" 3884 + checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" 3885 + dependencies = [ 3886 + "assert-json-diff", 3887 + "base64", 3888 + "deadpool", 3889 + "futures", 3890 + "http", 3891 + "http-body-util", 3892 + "hyper", 3893 + "hyper-util", 3894 + "log", 3895 + "once_cell", 3896 + "regex", 3897 + "serde", 3898 + "serde_json", 3899 + "tokio", 3900 + "url", 3411 3901 ] 3412 3902 3413 3903 [[package]]
+1
Cargo.toml
··· 4 4 "codegen-plugins/mlf-codegen-go", 5 5 "codegen-plugins/mlf-codegen-rust", 6 6 "codegen-plugins/mlf-codegen-typescript", 7 + "mlf-atproto", 7 8 "mlf-cli", 8 9 "mlf-codegen", 9 10 "mlf-diagnostics",
+35 -12
codegen-plugins/mlf-codegen-go/src/lib.rs
··· 1 - use mlf_codegen::{register_generator, CodeGenerator, GeneratorContext}; 1 + use mlf_codegen::{CodeGenerator, GeneratorContext, register_generator}; 2 2 use mlf_lang::ast::*; 3 3 use std::fmt::Write; 4 4 ··· 7 7 impl GoGenerator { 8 8 pub const NAME: &'static str = "go"; 9 9 10 - fn generate_type(&self, ty: &Type, optional: bool, ctx: &GeneratorContext) -> Result<String, String> { 10 + fn generate_type( 11 + &self, 12 + ty: &Type, 13 + optional: bool, 14 + ctx: &GeneratorContext, 15 + ) -> Result<String, String> { 11 16 let base_type = match ty { 12 17 Type::Primitive { kind, .. } => match kind { 13 18 PrimitiveType::Null => "interface{}", ··· 16 21 PrimitiveType::String => "string", 17 22 PrimitiveType::Bytes => "[]byte", 18 23 PrimitiveType::Blob => "[]byte", // Annotation idea: @goType("custom.BlobType") 19 - }.to_string(), 24 + } 25 + .to_string(), 20 26 Type::Reference { path, .. } => { 21 27 let path_str = path.to_string(); 22 28 match path_str.as_str() { 23 29 // Map standard library types 24 30 "Datetime" => "string".to_string(), // ISO 8601 string 25 - "Did" | "AtUri" | "Cid" | "AtIdentifier" | "Handle" | "Nsid" | "Tid" | "RecordKey" | "Uri" | "Language" => { 26 - "string".to_string() 27 - } 31 + "Did" | "AtUri" | "Cid" | "AtIdentifier" | "Handle" | "Nsid" | "Tid" 32 + | "RecordKey" | "Uri" | "Language" => "string".to_string(), 28 33 _ => { 29 34 // Local reference 30 35 path.segments.last().unwrap().name.clone() ··· 50 55 if !field.docs.is_empty() { 51 56 write!(obj, "\t\t// {}\n", field.docs[0].text).unwrap(); 52 57 } 53 - write!(obj, "\t\t{} {} `json:\"{}", field_name, field_type, json_name).unwrap(); 58 + write!( 59 + obj, 60 + "\t\t{} {} `json:\"{}", 61 + field_name, field_type, json_name 62 + ) 63 + .unwrap(); 54 64 if field.optional { 55 65 write!(obj, ",omitempty").unwrap(); 56 66 } ··· 129 139 match item { 130 140 Item::Record(record) => { 131 141 output.push_str(&self.generate_doc_comment(&record.docs)); 132 - writeln!(output, "type {} struct {{", self.capitalize(&record.name.name)).unwrap(); 142 + writeln!( 143 + output, 144 + "type {} struct {{", 145 + self.capitalize(&record.name.name) 146 + ) 147 + .unwrap(); 133 148 134 149 for field in &record.fields { 135 150 let field_name = self.capitalize(&field.name.name); ··· 139 154 if !field.docs.is_empty() { 140 155 writeln!(output, "\t// {}", field.docs[0].text).unwrap(); 141 156 } 142 - write!(output, "\t{} {} `json:\"{}\"", field_name, field_type, json_name).unwrap(); 157 + write!( 158 + output, 159 + "\t{} {} `json:\"{}\"", 160 + field_name, field_type, json_name 161 + ) 162 + .unwrap(); 143 163 if field.optional { 144 164 write!(output, ",omitempty").unwrap(); 145 165 } ··· 160 180 "type {} {}\n", 161 181 type_name, 162 182 self.generate_type(&def.ty, false, ctx)? 163 - ).unwrap(); 183 + ) 184 + .unwrap(); 164 185 } 165 186 _ => { 166 187 // Other types become type aliases ··· 169 190 "type {} {}\n", 170 191 type_name, 171 192 self.generate_type(&def.ty, false, ctx)? 172 - ).unwrap(); 193 + ) 194 + .unwrap(); 173 195 } 174 196 } 175 197 } ··· 180 202 "type {} {}\n", 181 203 self.capitalize(&inline.name.name), 182 204 self.generate_type(&inline.ty, false, ctx)? 183 - ).unwrap(); 205 + ) 206 + .unwrap(); 184 207 } 185 208 Item::Token(token) => { 186 209 output.push_str(&self.generate_doc_comment(&token.docs));
+84 -21
codegen-plugins/mlf-codegen-rust/src/lib.rs
··· 1 - use mlf_codegen::{register_generator, CodeGenerator, GeneratorContext}; 1 + use mlf_codegen::{CodeGenerator, GeneratorContext, register_generator}; 2 2 use mlf_lang::ast::*; 3 3 use std::fmt::Write; 4 4 ··· 26 26 } 27 27 } 28 28 29 - fn generate_type(&self, ty: &Type, optional: bool, ctx: &GeneratorContext) -> Result<String, String> { 29 + fn generate_type( 30 + &self, 31 + ty: &Type, 32 + optional: bool, 33 + ctx: &GeneratorContext, 34 + ) -> Result<String, String> { 30 35 let base_type = match ty { 31 36 Type::Primitive { kind, .. } => match kind { 32 37 PrimitiveType::Null => "()".to_string(), // Unit type for null ··· 41 46 match path_str.as_str() { 42 47 // Map standard library types 43 48 "Datetime" => "String".to_string(), // ISO 8601 string, could use chrono::DateTime 44 - "Did" | "AtUri" | "Cid" | "AtIdentifier" | "Handle" | "Nsid" | "Tid" | "RecordKey" | "Uri" | "Language" => { 45 - "String".to_string() 46 - } 49 + "Did" | "AtUri" | "Cid" | "AtIdentifier" | "Handle" | "Nsid" | "Tid" 50 + | "RecordKey" | "Uri" | "Language" => "String".to_string(), 47 51 _ => { 48 52 // Local reference - convert to PascalCase 49 53 self.to_pascal_case(&path.segments.last().unwrap().name) ··· 58 62 // Rust doesn't have direct union types, use an enum 59 63 // For now, generate a simple representation 60 64 // Annotation idea: @rustEnum to customize enum generation 61 - if types.len() == 2 && matches!(types[0], Type::Primitive { kind: PrimitiveType::Null, .. }) { 65 + if types.len() == 2 66 + && matches!( 67 + types[0], 68 + Type::Primitive { 69 + kind: PrimitiveType::Null, 70 + .. 71 + } 72 + ) 73 + { 62 74 // Special case: null | T becomes Option<T> 63 75 return self.generate_type(&types[1], true, ctx); 64 - } else if types.len() == 2 && matches!(types[1], Type::Primitive { kind: PrimitiveType::Null, .. }) { 76 + } else if types.len() == 2 77 + && matches!( 78 + types[1], 79 + Type::Primitive { 80 + kind: PrimitiveType::Null, 81 + .. 82 + } 83 + ) 84 + { 65 85 return self.generate_type(&types[0], true, ctx); 66 86 } 67 87 // Otherwise use serde_json::Value for flexibility ··· 135 155 Item::Record(record) => { 136 156 output.push_str(&self.generate_doc_comment(&record.docs)); 137 157 writeln!(output, "#[derive(Debug, Clone, Serialize, Deserialize)]").unwrap(); 138 - writeln!(output, "pub struct {} {{", self.to_pascal_case(&record.name.name)).unwrap(); 158 + writeln!( 159 + output, 160 + "pub struct {} {{", 161 + self.to_pascal_case(&record.name.name) 162 + ) 163 + .unwrap(); 139 164 140 165 for field in &record.fields { 141 166 if !field.docs.is_empty() { ··· 144 169 145 170 // Use serde rename for camelCase fields 146 171 if field.name.name != self.to_snake_case(&field.name.name) { 147 - writeln!(output, " #[serde(rename = \"{}\")]", field.name.name).unwrap(); 172 + writeln!(output, " #[serde(rename = \"{}\")]", field.name.name) 173 + .unwrap(); 148 174 } 149 175 150 176 // Skip serializing None values for optional fields 151 177 if field.optional { 152 - writeln!(output, " #[serde(skip_serializing_if = \"Option::is_none\")]").unwrap(); 178 + writeln!( 179 + output, 180 + " #[serde(skip_serializing_if = \"Option::is_none\")]" 181 + ) 182 + .unwrap(); 153 183 } 154 184 155 185 let field_type = self.generate_type(&field.ty, field.optional, ctx)?; 156 - writeln!(output, " pub {}: {},", self.to_snake_case(&field.name.name), field_type).unwrap(); 186 + writeln!( 187 + output, 188 + " pub {}: {},", 189 + self.to_snake_case(&field.name.name), 190 + field_type 191 + ) 192 + .unwrap(); 157 193 } 158 194 159 195 writeln!(output, "}}\n").unwrap(); ··· 164 200 match &def.ty { 165 201 Type::Object { fields, .. } => { 166 202 // Generate a struct for object types 167 - writeln!(output, "#[derive(Debug, Clone, Serialize, Deserialize)]").unwrap(); 168 - writeln!(output, "pub struct {} {{", self.to_pascal_case(&def.name.name)).unwrap(); 203 + writeln!(output, "#[derive(Debug, Clone, Serialize, Deserialize)]") 204 + .unwrap(); 205 + writeln!( 206 + output, 207 + "pub struct {} {{", 208 + self.to_pascal_case(&def.name.name) 209 + ) 210 + .unwrap(); 169 211 170 212 for field in fields { 171 213 if !field.docs.is_empty() { ··· 173 215 } 174 216 175 217 if field.name.name != self.to_snake_case(&field.name.name) { 176 - writeln!(output, " #[serde(rename = \"{}\")]", field.name.name).unwrap(); 218 + writeln!( 219 + output, 220 + " #[serde(rename = \"{}\")]", 221 + field.name.name 222 + ) 223 + .unwrap(); 177 224 } 178 225 179 226 if field.optional { 180 - writeln!(output, " #[serde(skip_serializing_if = \"Option::is_none\")]").unwrap(); 227 + writeln!( 228 + output, 229 + " #[serde(skip_serializing_if = \"Option::is_none\")]" 230 + ) 231 + .unwrap(); 181 232 } 182 233 183 - let field_type = self.generate_type(&field.ty, field.optional, ctx)?; 184 - writeln!(output, " pub {}: {},", self.to_snake_case(&field.name.name), field_type).unwrap(); 234 + let field_type = 235 + self.generate_type(&field.ty, field.optional, ctx)?; 236 + writeln!( 237 + output, 238 + " pub {}: {},", 239 + self.to_snake_case(&field.name.name), 240 + field_type 241 + ) 242 + .unwrap(); 185 243 } 186 244 187 245 writeln!(output, "}}\n").unwrap(); ··· 193 251 "pub type {} = {};\n", 194 252 self.to_pascal_case(&def.name.name), 195 253 self.generate_type(&def.ty, false, ctx)? 196 - ).unwrap(); 254 + ) 255 + .unwrap(); 197 256 } 198 257 } 199 258 } ··· 204 263 "pub type {} = {};\n", 205 264 self.to_pascal_case(&inline.name.name), 206 265 self.generate_type(&inline.ty, false, ctx)? 207 - ).unwrap(); 266 + ) 267 + .unwrap(); 208 268 } 209 269 Item::Token(token) => { 210 270 output.push_str(&self.generate_doc_comment(&token.docs)); 211 - writeln!(output, "pub const {}: &str = \"{}\";\n", 271 + writeln!( 272 + output, 273 + "pub const {}: &str = \"{}\";\n", 212 274 token.name.name.to_uppercase(), 213 275 token.name.name 214 - ).unwrap(); 276 + ) 277 + .unwrap(); 215 278 } 216 279 Item::Query(_) | Item::Procedure(_) | Item::Subscription(_) => { 217 280 // TODO: Generate client methods
+15 -11
codegen-plugins/mlf-codegen-typescript/src/lib.rs
··· 1 - use mlf_codegen::{register_generator, CodeGenerator, GeneratorContext}; 1 + use mlf_codegen::{CodeGenerator, GeneratorContext, register_generator}; 2 2 use mlf_lang::ast::*; 3 3 use std::fmt::Write; 4 4 ··· 24 24 // Map standard library types to TypeScript types 25 25 Ok(match path_str.as_str() { 26 26 "Datetime" => "string".to_string(), // ISO 8601 27 - "Did" | "AtUri" | "Cid" | "AtIdentifier" | "Handle" | "Nsid" | "Tid" | "RecordKey" | "Uri" | "Language" => { 28 - "string".to_string() 29 - } 27 + "Did" | "AtUri" | "Cid" | "AtIdentifier" | "Handle" | "Nsid" | "Tid" 28 + | "RecordKey" | "Uri" | "Language" => "string".to_string(), 30 29 _ => { 31 30 // Local or cross-file reference 32 31 path.segments.last().unwrap().name.clone() ··· 38 37 Ok(format!("{}[]", inner_type)) 39 38 } 40 39 Type::Union { types, .. } => { 41 - let type_strings: Result<Vec<_>, _> = types 42 - .iter() 43 - .map(|t| self.generate_type(t, ctx)) 44 - .collect(); 40 + let type_strings: Result<Vec<_>, _> = 41 + types.iter().map(|t| self.generate_type(t, ctx)).collect(); 45 42 Ok(type_strings?.join(" | ")) 46 43 } 47 44 Type::Object { fields, .. } => { ··· 143 140 "export type {} = {};\n", 144 141 def.name.name, 145 142 self.generate_type(&def.ty, ctx)? 146 - ).unwrap(); 143 + ) 144 + .unwrap(); 147 145 } 148 146 Item::InlineType(inline) => { 149 147 output.push_str(&self.generate_doc_comment(&inline.docs)); ··· 152 150 "export type {} = {};\n", 153 151 inline.name.name, 154 152 self.generate_type(&inline.ty, ctx)? 155 - ).unwrap(); 153 + ) 154 + .unwrap(); 156 155 } 157 156 Item::Token(token) => { 158 157 output.push_str(&self.generate_doc_comment(&token.docs)); 159 - writeln!(output, "export const {} = Symbol('{}');\n", token.name.name, token.name.name).unwrap(); 158 + writeln!( 159 + output, 160 + "export const {} = Symbol('{}');\n", 161 + token.name.name, token.name.name 162 + ) 163 + .unwrap(); 160 164 } 161 165 Item::Query(_) | Item::Procedure(_) | Item::Subscription(_) => { 162 166 // TODO: Generate client methods for these
+22
mlf-atproto/Cargo.toml
··· 1 + [package] 2 + name = "mlf-atproto" 3 + version = "0.1.0" 4 + edition = "2024" 5 + license = "MIT" 6 + description = "ATProto protocol plumbing for MLF: identity, XRPC, session, records, CID" 7 + 8 + [dependencies] 9 + async-trait = "0.1" 10 + hickory-resolver = "0.24" 11 + reqwest = { version = "0.12", features = ["json"] } 12 + serde = { version = "1.0", features = ["derive"] } 13 + serde_json = "1.0" 14 + serde_ipld_dagcbor = "0.6" 15 + cid = "0.11" 16 + multihash-codetable = { version = "0.2", features = ["sha2"] } 17 + thiserror = "2.0" 18 + tokio = { version = "1", features = ["rt"] } 19 + 20 + [dev-dependencies] 21 + tokio = { version = "1", features = ["full"] } 22 + wiremock = "0.6"
+69
mlf-atproto/src/cid.rs
··· 1 + //! Client-side CID (Content Identifier) computation for AT Protocol records. 2 + //! 3 + //! Records are addressed by CIDv1 over a DAG-CBOR encoding with a 4 + //! SHA-256 multihash — this module produces exactly that. 5 + //! 6 + //! Use case: computing a local record's CID so we can diff against 7 + //! the `cid` a PDS returns from `listRecords` / `getRecord` without a 8 + //! network round-trip. 9 + 10 + use ::cid::Cid as Multiformat; 11 + use multihash_codetable::{Code, MultihashDigest}; 12 + 13 + #[derive(thiserror::Error, Debug)] 14 + pub enum CidError { 15 + #[error("DAG-CBOR encoding failed: {0}")] 16 + CborEncode(String), 17 + } 18 + 19 + /// DAG-CBOR codec code, per multicodec table. 20 + const DAG_CBOR: u64 = 0x71; 21 + 22 + /// Compute the CIDv1 of a JSON value by encoding it as DAG-CBOR and 23 + /// hashing with SHA-256. 24 + /// 25 + /// Produces the base32-encoded `bafy…` string that AT Protocol uses. 26 + pub fn cid_for_json(value: &serde_json::Value) -> Result<String, CidError> { 27 + let bytes = 28 + serde_ipld_dagcbor::to_vec(value).map_err(|e| CidError::CborEncode(e.to_string()))?; 29 + Ok(cid_for_dag_cbor_bytes(&bytes)) 30 + } 31 + 32 + /// Compute the CIDv1 of pre-encoded DAG-CBOR bytes. 33 + pub fn cid_for_dag_cbor_bytes(bytes: &[u8]) -> String { 34 + let hash = Code::Sha2_256.digest(bytes); 35 + let cid = Multiformat::new_v1(DAG_CBOR, hash); 36 + cid.to_string() 37 + } 38 + 39 + #[cfg(test)] 40 + mod tests { 41 + use super::*; 42 + use serde_json::json; 43 + 44 + #[test] 45 + fn round_trip_is_deterministic() { 46 + let v = json!({"a": 1, "b": "hello"}); 47 + let a = cid_for_json(&v).unwrap(); 48 + let b = cid_for_json(&v).unwrap(); 49 + assert_eq!(a, b); 50 + assert!(a.starts_with("bafy"), "expected bafy-prefixed CID, got {a}"); 51 + } 52 + 53 + #[test] 54 + fn different_content_has_different_cid() { 55 + let a = cid_for_json(&json!({"k": 1})).unwrap(); 56 + let b = cid_for_json(&json!({"k": 2})).unwrap(); 57 + assert_ne!(a, b); 58 + } 59 + 60 + #[test] 61 + fn field_order_does_not_matter_for_cbor_canonical_form() { 62 + // serde_ipld_dagcbor sorts object keys in canonical order, 63 + // so different insertion orders in the JSON source produce the 64 + // same DAG-CBOR bytes and thus the same CID. 65 + let a = cid_for_json(&json!({"a": 1, "b": 2})).unwrap(); 66 + let b = cid_for_json(&json!({"b": 2, "a": 1})).unwrap(); 67 + assert_eq!(a, b); 68 + } 69 + }
+346
mlf-atproto/src/identity.rs
··· 1 + //! Identity resolution for the AT Protocol. 2 + //! 3 + //! DID parsing and document resolution (`did:plc:` via plc.directory, 4 + //! `did:web:` via well-known fetch). NSID parsing. `_lexicon.<authority>` 5 + //! TXT resolution for lexicon publishing authorities. 6 + //! 7 + //! DNS is an implementation detail — the transport for the `_lexicon` 8 + //! convention — rather than its own module. 9 + 10 + use async_trait::async_trait; 11 + use hickory_resolver::TokioAsyncResolver; 12 + use hickory_resolver::config::{ResolverConfig, ResolverOpts}; 13 + use std::collections::HashMap; 14 + use std::sync::{Arc, Mutex}; 15 + 16 + #[derive(thiserror::Error, Debug)] 17 + pub enum IdentityError { 18 + #[error("Invalid NSID format: {0}")] 19 + InvalidNsid(String), 20 + 21 + #[error("Invalid DID format: {0}")] 22 + InvalidDid(String), 23 + 24 + #[error("DNS lookup failed for {domain}: {error}")] 25 + DnsLookupFailed { domain: String, error: String }, 26 + 27 + #[error("No `did=` entry in TXT record for {0}")] 28 + NoDidInTxt(String), 29 + 30 + #[error("DID resolution failed for {did}: {error}")] 31 + DidResolutionFailed { did: String, error: String }, 32 + 33 + #[error("No PDS service endpoint in DID document for {0}")] 34 + NoPdsEndpoint(String), 35 + 36 + #[error("Unsupported DID method: {0}")] 37 + UnsupportedDidMethod(String), 38 + } 39 + 40 + // --------------------------------------------------------------------------- 41 + // NSID parsing 42 + // --------------------------------------------------------------------------- 43 + 44 + /// Parse an NSID into (authority, name_segments). 45 + /// 46 + /// - `app.bsky.actor.profile` → `("app.bsky", "actor.profile")` 47 + /// - `place.stream.key` → `("place.stream", "key")` 48 + /// - `place.stream` → `("place.stream", "")` 49 + /// 50 + /// NSIDs must have at least 2 segments. 51 + pub fn parse_nsid(nsid: &str) -> Result<(String, String), IdentityError> { 52 + let parts: Vec<&str> = nsid.split('.').collect(); 53 + if parts.len() < 2 { 54 + return Err(IdentityError::InvalidNsid(format!( 55 + "NSID must have at least 2 segments: {nsid}" 56 + ))); 57 + } 58 + let authority = format!("{}.{}", parts[0], parts[1]); 59 + let name_segments = if parts.len() > 2 { 60 + parts[2..].join(".") 61 + } else { 62 + String::new() 63 + }; 64 + Ok((authority, name_segments)) 65 + } 66 + 67 + // --------------------------------------------------------------------------- 68 + // DNS name for _lexicon resolution 69 + // --------------------------------------------------------------------------- 70 + 71 + /// Construct the DNS name for an NSID's `_lexicon` TXT lookup. 72 + /// 73 + /// The NSID authority is reversed into DNS order, and any name segments 74 + /// are prepended ahead of it, all under the `_lexicon` prefix. 75 + /// 76 + /// - `("app.bsky", "actor.profile")` → `"_lexicon.actor.profile.bsky.app"` 77 + /// - `("place.stream", "")` → `"_lexicon.stream.place"` 78 + pub fn construct_dns_name(authority: &str, name_segments: &str) -> String { 79 + let reversed_auth: Vec<&str> = authority.split('.').rev().collect(); 80 + if name_segments.is_empty() { 81 + format!("_lexicon.{}", reversed_auth.join(".")) 82 + } else { 83 + format!("_lexicon.{}.{}", name_segments, reversed_auth.join(".")) 84 + } 85 + } 86 + 87 + // --------------------------------------------------------------------------- 88 + // DnsResolver trait 89 + // --------------------------------------------------------------------------- 90 + 91 + /// Resolver for `_lexicon.<authority>` TXT records. 92 + /// 93 + /// Mockable so tests can inject canned responses without network access. 94 + #[async_trait] 95 + pub trait DnsResolver: Send + Sync { 96 + /// Resolve an NSID (split into authority + name segments) to the DID 97 + /// it maps to via the `_lexicon` TXT convention. 98 + async fn resolve_lexicon_did( 99 + &self, 100 + authority: &str, 101 + name_segments: &str, 102 + ) -> Result<String, IdentityError>; 103 + } 104 + 105 + /// Production DNS resolver backed by hickory-resolver. 106 + pub struct RealDnsResolver { 107 + resolver: TokioAsyncResolver, 108 + } 109 + 110 + impl RealDnsResolver { 111 + pub fn new() -> Result<Self, IdentityError> { 112 + Ok(Self { 113 + resolver: TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()), 114 + }) 115 + } 116 + 117 + pub fn with_config(config: ResolverConfig, opts: ResolverOpts) -> Self { 118 + Self { 119 + resolver: TokioAsyncResolver::tokio(config, opts), 120 + } 121 + } 122 + } 123 + 124 + #[async_trait] 125 + impl DnsResolver for RealDnsResolver { 126 + async fn resolve_lexicon_did( 127 + &self, 128 + authority: &str, 129 + name_segments: &str, 130 + ) -> Result<String, IdentityError> { 131 + let dns_name = construct_dns_name(authority, name_segments); 132 + let response = self.resolver.txt_lookup(&dns_name).await.map_err(|e| { 133 + IdentityError::DnsLookupFailed { 134 + domain: dns_name.clone(), 135 + error: e.to_string(), 136 + } 137 + })?; 138 + for txt in response.iter() { 139 + for data in txt.txt_data() { 140 + let text = String::from_utf8_lossy(data); 141 + if let Some(did) = text.strip_prefix("did=") { 142 + return Ok(did.trim().to_string()); 143 + } 144 + } 145 + } 146 + Err(IdentityError::NoDidInTxt(dns_name)) 147 + } 148 + } 149 + 150 + /// In-memory DNS resolver for tests. 151 + #[derive(Clone, Default)] 152 + pub struct MockDnsResolver { 153 + records: Arc<Mutex<HashMap<String, String>>>, 154 + } 155 + 156 + impl MockDnsResolver { 157 + pub fn new() -> Self { 158 + Self::default() 159 + } 160 + 161 + /// Add a record keyed by (authority, name_segments). 162 + pub fn add_record(&mut self, authority: &str, name_segments: &str, did: String) { 163 + let dns_name = construct_dns_name(authority, name_segments); 164 + self.records.lock().unwrap().insert(dns_name, did); 165 + } 166 + 167 + /// Add a record from a full NSID. 168 + pub fn add_record_from_nsid(&mut self, nsid: &str, did: String) -> Result<(), IdentityError> { 169 + let (authority, name) = parse_nsid(nsid)?; 170 + self.add_record(&authority, &name, did); 171 + Ok(()) 172 + } 173 + } 174 + 175 + #[async_trait] 176 + impl DnsResolver for MockDnsResolver { 177 + async fn resolve_lexicon_did( 178 + &self, 179 + authority: &str, 180 + name_segments: &str, 181 + ) -> Result<String, IdentityError> { 182 + let dns_name = construct_dns_name(authority, name_segments); 183 + self.records 184 + .lock() 185 + .unwrap() 186 + .get(&dns_name) 187 + .cloned() 188 + .ok_or_else(|| IdentityError::DnsLookupFailed { 189 + domain: dns_name, 190 + error: "No mock record found".into(), 191 + }) 192 + } 193 + } 194 + 195 + // --------------------------------------------------------------------------- 196 + // DID → PDS endpoint resolution 197 + // --------------------------------------------------------------------------- 198 + 199 + /// Resolve a DID to its PDS (Personal Data Server) HTTPS endpoint. 200 + /// 201 + /// Supports `did:plc:*` (via https://plc.directory) and `did:web:*` 202 + /// (via `https://<domain>/.well-known/did.json`). 203 + pub async fn resolve_did_to_pds( 204 + client: &reqwest::Client, 205 + did: &str, 206 + ) -> Result<String, IdentityError> { 207 + let did_doc = fetch_did_document(client, did).await?; 208 + extract_pds_endpoint(&did_doc, did) 209 + } 210 + 211 + /// Fetch the DID document for a DID (PLC directory or `did:web:`). 212 + pub async fn fetch_did_document( 213 + client: &reqwest::Client, 214 + did: &str, 215 + ) -> Result<serde_json::Value, IdentityError> { 216 + let url = if let Some(domain) = did.strip_prefix("did:web:") { 217 + format!("https://{domain}/.well-known/did.json") 218 + } else if did.starts_with("did:plc:") { 219 + format!("https://plc.directory/{did}") 220 + } else { 221 + return Err(IdentityError::UnsupportedDidMethod(did.to_string())); 222 + }; 223 + 224 + let resp = client 225 + .get(&url) 226 + .send() 227 + .await 228 + .map_err(|e| IdentityError::DidResolutionFailed { 229 + did: did.to_string(), 230 + error: e.to_string(), 231 + })?; 232 + if !resp.status().is_success() { 233 + return Err(IdentityError::DidResolutionFailed { 234 + did: did.to_string(), 235 + error: format!("HTTP {}", resp.status()), 236 + }); 237 + } 238 + resp.json() 239 + .await 240 + .map_err(|e| IdentityError::DidResolutionFailed { 241 + did: did.to_string(), 242 + error: e.to_string(), 243 + }) 244 + } 245 + 246 + /// Extract the AtprotoPersonalDataServer endpoint from a DID document. 247 + fn extract_pds_endpoint(did_doc: &serde_json::Value, did: &str) -> Result<String, IdentityError> { 248 + let services = did_doc 249 + .get("service") 250 + .and_then(|v| v.as_array()) 251 + .ok_or_else(|| IdentityError::NoPdsEndpoint(did.to_string()))?; 252 + for service in services { 253 + if service.get("type").and_then(|v| v.as_str()) == Some("AtprotoPersonalDataServer") 254 + && let Some(ep) = service.get("serviceEndpoint").and_then(|v| v.as_str()) 255 + { 256 + return Ok(ep.trim_end_matches('/').to_string()); 257 + } 258 + } 259 + Err(IdentityError::NoPdsEndpoint(did.to_string())) 260 + } 261 + 262 + #[cfg(test)] 263 + mod tests { 264 + use super::*; 265 + 266 + #[test] 267 + fn test_parse_nsid() { 268 + assert_eq!( 269 + parse_nsid("place.stream.key").unwrap(), 270 + ("place.stream".into(), "key".into()) 271 + ); 272 + assert_eq!( 273 + parse_nsid("app.bsky.actor.profile").unwrap(), 274 + ("app.bsky".into(), "actor.profile".into()) 275 + ); 276 + assert_eq!( 277 + parse_nsid("place.stream").unwrap(), 278 + ("place.stream".into(), "".into()) 279 + ); 280 + assert!(parse_nsid("invalid").is_err()); 281 + } 282 + 283 + #[test] 284 + fn test_construct_dns_name() { 285 + assert_eq!( 286 + construct_dns_name("place.stream", "key"), 287 + "_lexicon.key.stream.place" 288 + ); 289 + assert_eq!( 290 + construct_dns_name("app.bsky", "actor"), 291 + "_lexicon.actor.bsky.app" 292 + ); 293 + assert_eq!( 294 + construct_dns_name("app.bsky", "actor.profile"), 295 + "_lexicon.actor.profile.bsky.app" 296 + ); 297 + assert_eq!( 298 + construct_dns_name("place.stream", ""), 299 + "_lexicon.stream.place" 300 + ); 301 + } 302 + 303 + #[tokio::test] 304 + async fn test_mock_dns_resolver() { 305 + let mut r = MockDnsResolver::new(); 306 + r.add_record("place.stream", "key", "did:plc:test".into()); 307 + assert_eq!( 308 + r.resolve_lexicon_did("place.stream", "key").await.unwrap(), 309 + "did:plc:test" 310 + ); 311 + assert!(r.resolve_lexicon_did("place.stream", "none").await.is_err()); 312 + } 313 + 314 + #[tokio::test] 315 + async fn test_mock_dns_from_nsid() { 316 + let mut r = MockDnsResolver::new(); 317 + r.add_record_from_nsid("app.bsky.actor.profile", "did:plc:bsky".into()) 318 + .unwrap(); 319 + assert_eq!( 320 + r.resolve_lexicon_did("app.bsky", "actor.profile") 321 + .await 322 + .unwrap(), 323 + "did:plc:bsky" 324 + ); 325 + } 326 + 327 + #[test] 328 + fn test_extract_pds_endpoint() { 329 + let doc = serde_json::json!({ 330 + "service": [ 331 + {"type": "Other", "serviceEndpoint": "https://nope"}, 332 + {"type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://pds.example.com/"}, 333 + ] 334 + }); 335 + assert_eq!( 336 + extract_pds_endpoint(&doc, "did:plc:x").unwrap(), 337 + "https://pds.example.com" 338 + ); 339 + } 340 + 341 + #[test] 342 + fn test_extract_pds_endpoint_missing() { 343 + let doc = serde_json::json!({"service": []}); 344 + assert!(extract_pds_endpoint(&doc, "did:plc:x").is_err()); 345 + } 346 + }
+45
mlf-atproto/src/lib.rs
··· 1 + //! ATProto protocol plumbing for MLF. 2 + //! 3 + //! Low-level, protocol-level operations against the AT Protocol ecosystem: 4 + //! 5 + //! - [`identity`] — DID parsing, PLC / `did:web` resolution, NSID parsing, 6 + //! `_lexicon.<authority>` TXT resolution 7 + //! - [`xrpc`] — generic XRPC HTTP client (query + procedure) 8 + //! - [`session`] — `createSession` + refresh, app-password auth 9 + //! - [`records`] — typed wrappers for `getRecord` / `putRecord` / 10 + //! `listRecords` / `deleteRecord` 11 + //! - [`cid`] — client-side CID computation (DAG-CBOR + SHA-256 multihash) 12 + //! 13 + //! This crate is plumbing only — no MLF-specific domain logic lives here. 14 + //! Consumers (`mlf-lexicon-fetcher`, `mlf-publish`) build domain operations 15 + //! on top of these primitives. 16 + 17 + pub mod cid; 18 + pub mod identity; 19 + pub mod records; 20 + pub mod session; 21 + pub mod xrpc; 22 + 23 + pub use identity::{DnsResolver, MockDnsResolver, RealDnsResolver}; 24 + 25 + /// Result alias used throughout this crate. 26 + pub type Result<T> = std::result::Result<T, Error>; 27 + 28 + /// Umbrella error type for every submodule. 29 + #[derive(thiserror::Error, Debug)] 30 + pub enum Error { 31 + #[error(transparent)] 32 + Identity(#[from] identity::IdentityError), 33 + 34 + #[error(transparent)] 35 + Xrpc(#[from] xrpc::XrpcError), 36 + 37 + #[error(transparent)] 38 + Session(#[from] session::SessionError), 39 + 40 + #[error(transparent)] 41 + Records(#[from] records::RecordError), 42 + 43 + #[error(transparent)] 44 + Cid(#[from] cid::CidError), 45 + }
+359
mlf-atproto/src/records.rs
··· 1 + //! Typed wrappers for the AT Protocol record CRUD XRPC verbs. 2 + //! 3 + //! Reads (`getRecord`, `listRecords`) are unauthed public queries. 4 + //! Writes (`putRecord`, `deleteRecord`) require a session access token. 5 + 6 + use crate::xrpc::{self, XrpcError}; 7 + use serde::{Deserialize, Serialize}; 8 + 9 + #[derive(thiserror::Error, Debug)] 10 + pub enum RecordError { 11 + #[error(transparent)] 12 + Xrpc(#[from] XrpcError), 13 + 14 + #[error("Record not found: {collection}/{rkey} in {repo}")] 15 + NotFound { 16 + repo: String, 17 + collection: String, 18 + rkey: String, 19 + }, 20 + } 21 + 22 + /// A single record as returned by `getRecord` / `listRecords`. 23 + #[derive(Debug, Clone, Serialize, Deserialize)] 24 + pub struct Record { 25 + pub uri: String, 26 + #[serde(default)] 27 + pub cid: Option<String>, 28 + pub value: serde_json::Value, 29 + } 30 + 31 + // --------------------------------------------------------------------------- 32 + // getRecord 33 + // --------------------------------------------------------------------------- 34 + 35 + #[derive(Serialize)] 36 + struct GetRecordParams<'a> { 37 + repo: &'a str, 38 + collection: &'a str, 39 + rkey: &'a str, 40 + } 41 + 42 + /// Fetch a single record by repo + collection + rkey. 43 + pub async fn get_record( 44 + client: &reqwest::Client, 45 + pds: &str, 46 + repo: &str, 47 + collection: &str, 48 + rkey: &str, 49 + ) -> Result<Record, RecordError> { 50 + let params = GetRecordParams { 51 + repo, 52 + collection, 53 + rkey, 54 + }; 55 + match xrpc::query::<_, Record>(client, pds, "com.atproto.repo.getRecord", &params, None).await { 56 + Ok(r) => Ok(r), 57 + Err(XrpcError::HttpStatus { status: 400, .. }) => Err(RecordError::NotFound { 58 + repo: repo.to_string(), 59 + collection: collection.to_string(), 60 + rkey: rkey.to_string(), 61 + }), 62 + Err(e) => Err(RecordError::Xrpc(e)), 63 + } 64 + } 65 + 66 + // --------------------------------------------------------------------------- 67 + // listRecords 68 + // --------------------------------------------------------------------------- 69 + 70 + #[derive(Serialize)] 71 + struct ListRecordsParams<'a> { 72 + repo: &'a str, 73 + collection: &'a str, 74 + #[serde(skip_serializing_if = "Option::is_none")] 75 + cursor: Option<&'a str>, 76 + #[serde(skip_serializing_if = "Option::is_none")] 77 + limit: Option<u32>, 78 + } 79 + 80 + #[derive(Debug, Deserialize)] 81 + pub struct ListRecordsPage { 82 + pub records: Vec<Record>, 83 + #[serde(default)] 84 + pub cursor: Option<String>, 85 + } 86 + 87 + /// Fetch a single page of records for a collection. 88 + /// 89 + /// For full enumeration, prefer [`list_all_records`]. 90 + pub async fn list_records_page( 91 + client: &reqwest::Client, 92 + pds: &str, 93 + repo: &str, 94 + collection: &str, 95 + cursor: Option<&str>, 96 + limit: Option<u32>, 97 + ) -> Result<ListRecordsPage, RecordError> { 98 + let params = ListRecordsParams { 99 + repo, 100 + collection, 101 + cursor, 102 + limit, 103 + }; 104 + xrpc::query::<_, ListRecordsPage>(client, pds, "com.atproto.repo.listRecords", &params, None) 105 + .await 106 + .map_err(Into::into) 107 + } 108 + 109 + /// Fetch every record in a collection, paginating as needed. 110 + pub async fn list_all_records( 111 + client: &reqwest::Client, 112 + pds: &str, 113 + repo: &str, 114 + collection: &str, 115 + ) -> Result<Vec<Record>, RecordError> { 116 + let mut out = Vec::new(); 117 + let mut cursor: Option<String> = None; 118 + loop { 119 + let page = 120 + list_records_page(client, pds, repo, collection, cursor.as_deref(), None).await?; 121 + out.extend(page.records); 122 + match page.cursor { 123 + Some(c) if !c.is_empty() => cursor = Some(c), 124 + _ => break, 125 + } 126 + } 127 + Ok(out) 128 + } 129 + 130 + // --------------------------------------------------------------------------- 131 + // putRecord (authed) 132 + // --------------------------------------------------------------------------- 133 + 134 + #[derive(Serialize)] 135 + struct PutRecordInput<'a> { 136 + repo: &'a str, 137 + collection: &'a str, 138 + rkey: &'a str, 139 + record: &'a serde_json::Value, 140 + } 141 + 142 + #[derive(Debug, Deserialize)] 143 + pub struct PutRecordOutput { 144 + pub uri: String, 145 + pub cid: String, 146 + } 147 + 148 + /// Create or replace a record at repo/collection/rkey. Requires an 149 + /// access JWT from an authenticated session. 150 + pub async fn put_record( 151 + client: &reqwest::Client, 152 + pds: &str, 153 + access_jwt: &str, 154 + repo: &str, 155 + collection: &str, 156 + rkey: &str, 157 + record: &serde_json::Value, 158 + ) -> Result<PutRecordOutput, RecordError> { 159 + let input = PutRecordInput { 160 + repo, 161 + collection, 162 + rkey, 163 + record, 164 + }; 165 + xrpc::procedure::<_, PutRecordOutput>( 166 + client, 167 + pds, 168 + "com.atproto.repo.putRecord", 169 + &input, 170 + Some(access_jwt), 171 + ) 172 + .await 173 + .map_err(Into::into) 174 + } 175 + 176 + // --------------------------------------------------------------------------- 177 + // deleteRecord (authed) 178 + // --------------------------------------------------------------------------- 179 + 180 + #[derive(Serialize)] 181 + struct DeleteRecordInput<'a> { 182 + repo: &'a str, 183 + collection: &'a str, 184 + rkey: &'a str, 185 + } 186 + 187 + /// Delete a record at repo/collection/rkey. Requires an access JWT. 188 + /// 189 + /// Returns `Ok(())` whether or not the record existed — the PDS returns 190 + /// success for idempotent deletes. 191 + pub async fn delete_record( 192 + client: &reqwest::Client, 193 + pds: &str, 194 + access_jwt: &str, 195 + repo: &str, 196 + collection: &str, 197 + rkey: &str, 198 + ) -> Result<(), RecordError> { 199 + let input = DeleteRecordInput { 200 + repo, 201 + collection, 202 + rkey, 203 + }; 204 + let _: serde_json::Value = xrpc::procedure( 205 + client, 206 + pds, 207 + "com.atproto.repo.deleteRecord", 208 + &input, 209 + Some(access_jwt), 210 + ) 211 + .await?; 212 + Ok(()) 213 + } 214 + 215 + #[cfg(test)] 216 + mod tests { 217 + use super::*; 218 + use serde_json::json; 219 + use wiremock::matchers::{bearer_token, body_partial_json, method, path, query_param}; 220 + use wiremock::{Mock, MockServer, ResponseTemplate}; 221 + 222 + #[tokio::test] 223 + async fn get_record_happy_path() { 224 + let server = MockServer::start().await; 225 + Mock::given(method("GET")) 226 + .and(path("/xrpc/com.atproto.repo.getRecord")) 227 + .and(query_param("repo", "did:plc:a")) 228 + .and(query_param("collection", "com.atproto.lexicon.schema")) 229 + .and(query_param("rkey", "com.example.thing")) 230 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 231 + "uri": "at://did:plc:a/com.atproto.lexicon.schema/com.example.thing", 232 + "cid": "bafyREAL", 233 + "value": {"id": "com.example.thing"}, 234 + }))) 235 + .mount(&server) 236 + .await; 237 + 238 + let client = reqwest::Client::new(); 239 + let r = get_record( 240 + &client, 241 + &server.uri(), 242 + "did:plc:a", 243 + "com.atproto.lexicon.schema", 244 + "com.example.thing", 245 + ) 246 + .await 247 + .unwrap(); 248 + assert_eq!(r.cid.as_deref(), Some("bafyREAL")); 249 + } 250 + 251 + #[tokio::test] 252 + async fn get_record_400_is_not_found() { 253 + let server = MockServer::start().await; 254 + Mock::given(method("GET")) 255 + .and(path("/xrpc/com.atproto.repo.getRecord")) 256 + .respond_with( 257 + ResponseTemplate::new(400).set_body_json(json!({"error":"RecordNotFound"})), 258 + ) 259 + .mount(&server) 260 + .await; 261 + 262 + let client = reqwest::Client::new(); 263 + let err = get_record(&client, &server.uri(), "did:plc:a", "c", "k") 264 + .await 265 + .unwrap_err(); 266 + assert!(matches!(err, RecordError::NotFound { .. })); 267 + } 268 + 269 + #[tokio::test] 270 + async fn list_all_paginates() { 271 + let server = MockServer::start().await; 272 + // First page returns cursor "p2". 273 + Mock::given(method("GET")) 274 + .and(path("/xrpc/com.atproto.repo.listRecords")) 275 + .and(query_param("repo", "did:plc:a")) 276 + .and(query_param("collection", "c")) 277 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 278 + "records": [{"uri":"at://did:plc:a/c/one","cid":"b1","value":{}}], 279 + "cursor": "p2" 280 + }))) 281 + .up_to_n_times(1) 282 + .mount(&server) 283 + .await; 284 + // Second page, no cursor. 285 + Mock::given(method("GET")) 286 + .and(path("/xrpc/com.atproto.repo.listRecords")) 287 + .and(query_param("cursor", "p2")) 288 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 289 + "records": [{"uri":"at://did:plc:a/c/two","cid":"b2","value":{}}] 290 + }))) 291 + .mount(&server) 292 + .await; 293 + 294 + let client = reqwest::Client::new(); 295 + let all = list_all_records(&client, &server.uri(), "did:plc:a", "c") 296 + .await 297 + .unwrap(); 298 + assert_eq!(all.len(), 2); 299 + assert!(all.iter().any(|r| r.uri.ends_with("/one"))); 300 + assert!(all.iter().any(|r| r.uri.ends_with("/two"))); 301 + } 302 + 303 + #[tokio::test] 304 + async fn put_record_sends_auth_and_body() { 305 + let server = MockServer::start().await; 306 + Mock::given(method("POST")) 307 + .and(path("/xrpc/com.atproto.repo.putRecord")) 308 + .and(bearer_token("access")) 309 + .and(body_partial_json(json!({ 310 + "repo": "did:plc:a", 311 + "collection": "com.atproto.lexicon.schema", 312 + "rkey": "com.example.thing", 313 + "record": {"id": "com.example.thing"} 314 + }))) 315 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 316 + "uri": "at://did:plc:a/com.atproto.lexicon.schema/com.example.thing", 317 + "cid": "bafyNEW" 318 + }))) 319 + .mount(&server) 320 + .await; 321 + 322 + let client = reqwest::Client::new(); 323 + let out = put_record( 324 + &client, 325 + &server.uri(), 326 + "access", 327 + "did:plc:a", 328 + "com.atproto.lexicon.schema", 329 + "com.example.thing", 330 + &json!({"id": "com.example.thing"}), 331 + ) 332 + .await 333 + .unwrap(); 334 + assert_eq!(out.cid, "bafyNEW"); 335 + } 336 + 337 + #[tokio::test] 338 + async fn delete_record_sends_auth_and_body() { 339 + let server = MockServer::start().await; 340 + Mock::given(method("POST")) 341 + .and(path("/xrpc/com.atproto.repo.deleteRecord")) 342 + .and(bearer_token("access")) 343 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) 344 + .mount(&server) 345 + .await; 346 + 347 + let client = reqwest::Client::new(); 348 + delete_record( 349 + &client, 350 + &server.uri(), 351 + "access", 352 + "did:plc:a", 353 + "com.atproto.lexicon.schema", 354 + "com.example.thing", 355 + ) 356 + .await 357 + .unwrap(); 358 + } 359 + }
+194
mlf-atproto/src/session.rs
··· 1 + //! PDS session management. 2 + //! 3 + //! App-password authentication via `com.atproto.server.createSession` 4 + //! plus token refresh via `com.atproto.server.refreshSession`. A [`Session`] 5 + //! holds the credentials needed for authed XRPC calls. 6 + 7 + use crate::xrpc::{self, XrpcError}; 8 + use serde::{Deserialize, Serialize}; 9 + 10 + #[derive(thiserror::Error, Debug)] 11 + pub enum SessionError { 12 + #[error(transparent)] 13 + Xrpc(#[from] XrpcError), 14 + 15 + #[error("Invalid credentials (handle or password rejected by PDS)")] 16 + InvalidCredentials, 17 + 18 + #[error("Session refresh failed: {0}")] 19 + RefreshFailed(String), 20 + } 21 + 22 + /// An authenticated PDS session. 23 + #[derive(Debug, Clone, Serialize, Deserialize)] 24 + pub struct Session { 25 + /// Publishing DID (from the session response). 26 + pub did: String, 27 + /// The user's handle at session creation time. 28 + pub handle: String, 29 + /// Short-lived bearer token for authed XRPC calls. 30 + #[serde(rename = "accessJwt")] 31 + pub access_jwt: String, 32 + /// Long-lived token for refreshing expired sessions. 33 + #[serde(rename = "refreshJwt")] 34 + pub refresh_jwt: String, 35 + } 36 + 37 + #[derive(Serialize)] 38 + struct CreateSessionInput<'a> { 39 + identifier: &'a str, 40 + password: &'a str, 41 + } 42 + 43 + /// Create a new session using a handle + app password. 44 + /// 45 + /// Hits `com.atproto.server.createSession` on the given PDS. 46 + pub async fn create_session( 47 + client: &reqwest::Client, 48 + pds: &str, 49 + identifier: &str, 50 + password: &str, 51 + ) -> Result<Session, SessionError> { 52 + let body = CreateSessionInput { 53 + identifier, 54 + password, 55 + }; 56 + match xrpc::procedure::<_, Session>( 57 + client, 58 + pds, 59 + "com.atproto.server.createSession", 60 + &body, 61 + None, 62 + ) 63 + .await 64 + { 65 + Ok(s) => Ok(s), 66 + Err(XrpcError::HttpStatus { status: 401, .. }) => Err(SessionError::InvalidCredentials), 67 + Err(e) => Err(SessionError::Xrpc(e)), 68 + } 69 + } 70 + 71 + /// Refresh an existing session using its `refreshJwt`. 72 + /// 73 + /// On success, returns the updated session. The original `Session` should 74 + /// be replaced with the returned one. 75 + pub async fn refresh_session( 76 + client: &reqwest::Client, 77 + pds: &str, 78 + refresh_jwt: &str, 79 + ) -> Result<Session, SessionError> { 80 + xrpc::procedure::<_, Session>( 81 + client, 82 + pds, 83 + "com.atproto.server.refreshSession", 84 + &serde_json::json!({}), 85 + Some(refresh_jwt), 86 + ) 87 + .await 88 + .map_err(|e| match e { 89 + XrpcError::HttpStatus { status, body, .. } => { 90 + SessionError::RefreshFailed(format!("HTTP {status}: {body}")) 91 + } 92 + other => SessionError::Xrpc(other), 93 + }) 94 + } 95 + 96 + /// Fetch the current session info using an access JWT. 97 + /// 98 + /// Useful for verifying a stored token is still valid before use. 99 + pub async fn get_session( 100 + client: &reqwest::Client, 101 + pds: &str, 102 + access_jwt: &str, 103 + ) -> Result<SessionInfo, SessionError> { 104 + xrpc::query::<_, SessionInfo>( 105 + client, 106 + pds, 107 + "com.atproto.server.getSession", 108 + &(), 109 + Some(access_jwt), 110 + ) 111 + .await 112 + .map_err(Into::into) 113 + } 114 + 115 + /// Response shape for `getSession` (a subset of the full session). 116 + #[derive(Debug, Clone, Serialize, Deserialize)] 117 + pub struct SessionInfo { 118 + pub did: String, 119 + pub handle: String, 120 + } 121 + 122 + #[cfg(test)] 123 + mod tests { 124 + use super::*; 125 + use serde_json::json; 126 + use wiremock::matchers::{body_partial_json, method, path}; 127 + use wiremock::{Mock, MockServer, ResponseTemplate}; 128 + 129 + #[tokio::test] 130 + async fn create_session_parses_response() { 131 + let server = MockServer::start().await; 132 + Mock::given(method("POST")) 133 + .and(path("/xrpc/com.atproto.server.createSession")) 134 + .and(body_partial_json(json!({ 135 + "identifier": "matt.example.com", 136 + "password": "hunter2", 137 + }))) 138 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 139 + "did": "did:plc:abc", 140 + "handle": "matt.example.com", 141 + "accessJwt": "access-token", 142 + "refreshJwt": "refresh-token", 143 + }))) 144 + .mount(&server) 145 + .await; 146 + 147 + let client = reqwest::Client::new(); 148 + let s = create_session(&client, &server.uri(), "matt.example.com", "hunter2") 149 + .await 150 + .unwrap(); 151 + assert_eq!(s.did, "did:plc:abc"); 152 + assert_eq!(s.handle, "matt.example.com"); 153 + assert_eq!(s.access_jwt, "access-token"); 154 + assert_eq!(s.refresh_jwt, "refresh-token"); 155 + } 156 + 157 + #[tokio::test] 158 + async fn create_session_401_is_invalid_credentials() { 159 + let server = MockServer::start().await; 160 + Mock::given(method("POST")) 161 + .and(path("/xrpc/com.atproto.server.createSession")) 162 + .respond_with(ResponseTemplate::new(401).set_body_json(json!({"error":"AuthRequired"}))) 163 + .mount(&server) 164 + .await; 165 + 166 + let client = reqwest::Client::new(); 167 + let err = create_session(&client, &server.uri(), "x", "wrong") 168 + .await 169 + .unwrap_err(); 170 + assert!(matches!(err, SessionError::InvalidCredentials)); 171 + } 172 + 173 + #[tokio::test] 174 + async fn refresh_session_parses_response() { 175 + let server = MockServer::start().await; 176 + Mock::given(method("POST")) 177 + .and(path("/xrpc/com.atproto.server.refreshSession")) 178 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 179 + "did": "did:plc:abc", 180 + "handle": "matt.example.com", 181 + "accessJwt": "new-access", 182 + "refreshJwt": "new-refresh", 183 + }))) 184 + .mount(&server) 185 + .await; 186 + 187 + let client = reqwest::Client::new(); 188 + let s = refresh_session(&client, &server.uri(), "old-refresh") 189 + .await 190 + .unwrap(); 191 + assert_eq!(s.access_jwt, "new-access"); 192 + assert_eq!(s.refresh_jwt, "new-refresh"); 193 + } 194 + }
+192
mlf-atproto/src/xrpc.rs
··· 1 + //! Generic XRPC HTTP client. 2 + //! 3 + //! XRPC is the AT Protocol's HTTP RPC convention: 4 + //! `GET <pds>/xrpc/<nsid>?param=value` — a query 5 + //! `POST <pds>/xrpc/<nsid>` with JSON body — a procedure 6 + //! 7 + //! This module provides `query` / `procedure` helpers that handle 8 + //! URL construction, auth headers, and JSON (de)serialization. 9 + //! Typed wrappers for specific NSIDs (getRecord, etc.) live in 10 + //! [`crate::records`] and [`crate::session`]. 11 + 12 + use serde::{Serialize, de::DeserializeOwned}; 13 + 14 + #[derive(thiserror::Error, Debug)] 15 + pub enum XrpcError { 16 + #[error("HTTP request failed: {0}")] 17 + Transport(String), 18 + 19 + #[error("XRPC {nsid} returned HTTP {status}: {body}")] 20 + HttpStatus { 21 + nsid: String, 22 + status: u16, 23 + body: String, 24 + }, 25 + 26 + #[error("Failed to parse XRPC response JSON: {0}")] 27 + ResponseParse(String), 28 + } 29 + 30 + /// Perform an XRPC query (GET). 31 + /// 32 + /// `params` is serialized as URL query parameters. `auth` is an optional 33 + /// bearer token (the `accessJwt` from a session). 34 + pub async fn query<P, R>( 35 + client: &reqwest::Client, 36 + pds: &str, 37 + nsid: &str, 38 + params: &P, 39 + auth: Option<&str>, 40 + ) -> Result<R, XrpcError> 41 + where 42 + P: Serialize, 43 + R: DeserializeOwned, 44 + { 45 + let url = format!("{}/xrpc/{}", pds.trim_end_matches('/'), nsid); 46 + let mut req = client.get(&url).query(params); 47 + if let Some(token) = auth { 48 + req = req.bearer_auth(token); 49 + } 50 + send(req, nsid).await 51 + } 52 + 53 + /// Perform an XRPC procedure (POST with JSON body). 54 + pub async fn procedure<B, R>( 55 + client: &reqwest::Client, 56 + pds: &str, 57 + nsid: &str, 58 + body: &B, 59 + auth: Option<&str>, 60 + ) -> Result<R, XrpcError> 61 + where 62 + B: Serialize, 63 + R: DeserializeOwned, 64 + { 65 + let url = format!("{}/xrpc/{}", pds.trim_end_matches('/'), nsid); 66 + let mut req = client.post(&url).json(body); 67 + if let Some(token) = auth { 68 + req = req.bearer_auth(token); 69 + } 70 + send(req, nsid).await 71 + } 72 + 73 + async fn send<R: DeserializeOwned>( 74 + req: reqwest::RequestBuilder, 75 + nsid: &str, 76 + ) -> Result<R, XrpcError> { 77 + let resp = req 78 + .send() 79 + .await 80 + .map_err(|e| XrpcError::Transport(e.to_string()))?; 81 + let status = resp.status(); 82 + if !status.is_success() { 83 + let body = resp.text().await.unwrap_or_default(); 84 + return Err(XrpcError::HttpStatus { 85 + nsid: nsid.to_string(), 86 + status: status.as_u16(), 87 + body, 88 + }); 89 + } 90 + resp.json::<R>() 91 + .await 92 + .map_err(|e| XrpcError::ResponseParse(e.to_string())) 93 + } 94 + 95 + #[cfg(test)] 96 + mod tests { 97 + use super::*; 98 + use serde_json::json; 99 + use wiremock::matchers::{bearer_token, method, path, query_param}; 100 + use wiremock::{Mock, MockServer, ResponseTemplate}; 101 + 102 + #[tokio::test] 103 + async fn query_builds_url_and_parses_response() { 104 + let server = MockServer::start().await; 105 + Mock::given(method("GET")) 106 + .and(path("/xrpc/com.example.thing")) 107 + .and(query_param("who", "me")) 108 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"value": 42}))) 109 + .mount(&server) 110 + .await; 111 + 112 + let client = reqwest::Client::new(); 113 + let got: serde_json::Value = query( 114 + &client, 115 + &server.uri(), 116 + "com.example.thing", 117 + &[("who", "me")], 118 + None, 119 + ) 120 + .await 121 + .unwrap(); 122 + assert_eq!(got, json!({"value": 42})); 123 + } 124 + 125 + #[tokio::test] 126 + async fn query_sends_bearer_token() { 127 + let server = MockServer::start().await; 128 + Mock::given(method("GET")) 129 + .and(path("/xrpc/com.example.thing")) 130 + .and(bearer_token("tok")) 131 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) 132 + .mount(&server) 133 + .await; 134 + 135 + let client = reqwest::Client::new(); 136 + let _: serde_json::Value = query( 137 + &client, 138 + &server.uri(), 139 + "com.example.thing", 140 + &(), 141 + Some("tok"), 142 + ) 143 + .await 144 + .unwrap(); 145 + } 146 + 147 + #[tokio::test] 148 + async fn procedure_posts_json_body() { 149 + let server = MockServer::start().await; 150 + Mock::given(method("POST")) 151 + .and(path("/xrpc/com.example.do")) 152 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"out": "yes"}))) 153 + .mount(&server) 154 + .await; 155 + 156 + let client = reqwest::Client::new(); 157 + let got: serde_json::Value = procedure( 158 + &client, 159 + &server.uri(), 160 + "com.example.do", 161 + &json!({"in": "yes"}), 162 + None, 163 + ) 164 + .await 165 + .unwrap(); 166 + assert_eq!(got, json!({"out": "yes"})); 167 + } 168 + 169 + #[tokio::test] 170 + async fn http_error_surfaces_status_and_body() { 171 + let server = MockServer::start().await; 172 + Mock::given(method("GET")) 173 + .and(path("/xrpc/com.example.oops")) 174 + .respond_with(ResponseTemplate::new(418).set_body_string("teapot")) 175 + .mount(&server) 176 + .await; 177 + 178 + let client = reqwest::Client::new(); 179 + let err = 180 + query::<_, serde_json::Value>(&client, &server.uri(), "com.example.oops", &(), None) 181 + .await 182 + .unwrap_err(); 183 + match err { 184 + XrpcError::HttpStatus { status, body, nsid } => { 185 + assert_eq!(status, 418); 186 + assert_eq!(body, "teapot"); 187 + assert_eq!(nsid, "com.example.oops"); 188 + } 189 + e => panic!("unexpected error: {e:?}"), 190 + } 191 + } 192 + }
+96 -64
mlf-cli/src/check.rs
··· 1 - use crate::config::{find_project_root, get_mlf_cache_dir, ConfigError, MlfConfig}; 1 + use crate::config::{ConfigError, MlfConfig, find_project_root, get_mlf_cache_dir}; 2 2 use crate::workspace_ext::workspace_with_std_and_cache; 3 3 use miette::Diagnostic; 4 4 use mlf_diagnostics::{ParseDiagnostic, ValidationDiagnostic}; ··· 37 37 help: Option<String>, 38 38 }, 39 39 40 - 41 40 #[error("Record validation failed")] 42 41 #[diagnostic(code(mlf::check::record_validation))] 43 42 RecordValidation { ··· 49 48 ConfigError(#[from] ConfigError), 50 49 } 51 50 52 - pub fn run_check(input_paths: Vec<PathBuf>, explicit_root: Option<PathBuf>) -> Result<(), CheckError> { 53 - let current_dir = std::env::current_dir() 54 - .map_err(|e| CheckError::ReadFile { 55 - path: ".".to_string(), 56 - source: e, 57 - })?; 51 + pub fn run_check( 52 + input_paths: Vec<PathBuf>, 53 + explicit_root: Option<PathBuf>, 54 + ) -> Result<(), CheckError> { 55 + let current_dir = std::env::current_dir().map_err(|e| CheckError::ReadFile { 56 + path: ".".to_string(), 57 + source: e, 58 + })?; 58 59 59 60 // Determine root directory and input paths 60 61 let (root_dir, file_paths) = if input_paths.is_empty() { ··· 65 66 let config = MlfConfig::load(&config_path)?; 66 67 let source_dir = project_root.join(&config.source.directory); 67 68 let root = explicit_root.unwrap_or_else(|| source_dir.clone()); 68 - println!("Using source directory from mlf.toml: {}", config.source.directory); 69 + println!( 70 + "Using source directory from mlf.toml: {}", 71 + config.source.directory 72 + ); 69 73 70 74 // Collect all .mlf files from source directory 71 75 let files = collect_mlf_files(&source_dir)?; ··· 120 124 }; 121 125 122 126 // Try to load cached lexicons from .mlf directory 123 - let current_dir = std::env::current_dir() 124 - .map_err(|e| CheckError::ReadFile { 125 - path: ".".to_string(), 126 - source: e, 127 - })?; 127 + let current_dir = std::env::current_dir().map_err(|e| CheckError::ReadFile { 128 + path: ".".to_string(), 129 + source: e, 130 + })?; 128 131 129 132 let mlf_cache_dir = find_project_root(&current_dir) 130 133 .ok() ··· 141 144 let mut had_parse_errors = false; 142 145 143 146 for file_path in &file_paths { 144 - let source = std::fs::read_to_string(file_path).map_err(|source| { 145 - CheckError::ReadFile { 146 - path: file_path.display().to_string(), 147 - source, 148 - } 147 + let source = std::fs::read_to_string(file_path).map_err(|source| CheckError::ReadFile { 148 + path: file_path.display().to_string(), 149 + source, 149 150 })?; 150 151 151 152 let filename = file_path.display().to_string(); ··· 163 164 let namespace = extract_namespace(&file_path, &root_dir)?; 164 165 165 166 if let Err(e) = workspace.add_module(namespace.clone(), lexicon.clone()) { 166 - let diagnostic = ValidationDiagnostic::new(filename.clone(), source.clone(), namespace.clone(), e); 167 + let diagnostic = 168 + ValidationDiagnostic::new(filename.clone(), source.clone(), namespace.clone(), e); 167 169 eprintln!("{:?}", miette::Report::new(diagnostic)); 168 170 had_parse_errors = true; 169 171 continue; ··· 181 183 182 184 if let Err(e) = workspace.resolve() { 183 185 // Collect all modules that have errors 184 - let mut modules_with_errors: std::collections::BTreeMap<String, (Option<String>, String)> = std::collections::BTreeMap::new(); 186 + let mut modules_with_errors: std::collections::BTreeMap<String, (Option<String>, String)> = 187 + std::collections::BTreeMap::new(); 185 188 186 189 // First, add all explicitly checked files 187 190 for (filename, namespace, source) in &source_files { ··· 198 201 // Try multiple locations for the source file 199 202 let mut possible_paths = vec![ 200 203 // Check in lexicons/ directory (common structure) 201 - current_dir.join("lexicons").join(format!("{}.mlf", namespace_path)), 204 + current_dir 205 + .join("lexicons") 206 + .join(format!("{}.mlf", namespace_path)), 202 207 // Check in source directory from config 203 - current_dir.join("src").join(format!("{}.mlf", namespace_path)), 208 + current_dir 209 + .join("src") 210 + .join(format!("{}.mlf", namespace_path)), 204 211 // Check relative to current directory 205 212 current_dir.join(format!("{}.mlf", namespace_path)), 206 213 ]; 207 214 208 215 // Add cache directory if available (lexicons are in lexicons/mlf/ subdirectory) 209 216 if let Some(cache_dir) = &mlf_cache_dir { 210 - possible_paths.push(cache_dir.join("lexicons").join("mlf").join(format!("{}.mlf", namespace_path))); 217 + possible_paths.push( 218 + cache_dir 219 + .join("lexicons") 220 + .join("mlf") 221 + .join(format!("{}.mlf", namespace_path)), 222 + ); 211 223 } 212 224 213 225 for path in possible_paths { 214 226 if let Ok(source) = std::fs::read_to_string(&path) { 215 227 modules_with_errors.insert( 216 228 error_namespace.to_string(), 217 - (Some(path.display().to_string()), source) 229 + (Some(path.display().to_string()), source), 218 230 ); 219 231 source_loaded = true; 220 232 break; ··· 223 235 224 236 if !source_loaded { 225 237 // Couldn't load source, add placeholder 226 - modules_with_errors.insert( 227 - error_namespace.to_string(), 228 - (None, String::new()) 229 - ); 238 + modules_with_errors.insert(error_namespace.to_string(), (None, String::new())); 230 239 } 231 240 } 232 241 } ··· 234 243 // Show diagnostics for all modules with errors 235 244 for (namespace, (filename_opt, source)) in &modules_with_errors { 236 245 // Only show diagnostic if this module has errors 237 - let has_errors = e.errors.iter().any(|error| { 238 - mlf_diagnostics::get_error_module_namespace_str(error) == namespace 239 - }); 246 + let has_errors = e 247 + .errors 248 + .iter() 249 + .any(|error| mlf_diagnostics::get_error_module_namespace_str(error) == namespace); 240 250 241 251 if has_errors { 242 252 if let Some(filename) = filename_opt { 243 253 // Have source file, show full diagnostic 244 - let diagnostic = ValidationDiagnostic::new(filename.clone(), source.clone(), namespace.clone(), e.clone()); 254 + let diagnostic = ValidationDiagnostic::new( 255 + filename.clone(), 256 + source.clone(), 257 + namespace.clone(), 258 + e.clone(), 259 + ); 245 260 eprintln!("{:?}", miette::Report::new(diagnostic)); 246 261 } else { 247 262 // No source available, just list the errors 248 - let error_count = e.errors.iter() 249 - .filter(|err| mlf_diagnostics::get_error_module_namespace_str(err) == namespace) 263 + let error_count = e 264 + .errors 265 + .iter() 266 + .filter(|err| { 267 + mlf_diagnostics::get_error_module_namespace_str(err) == namespace 268 + }) 250 269 .count(); 251 - eprintln!("\n{}: {} error(s) (source not available)", namespace, error_count); 270 + eprintln!( 271 + "\n{}: {} error(s) (source not available)", 272 + namespace, error_count 273 + ); 252 274 } 253 275 } 254 276 } ··· 263 285 } 264 286 265 287 pub fn validate(lexicon_path: PathBuf, record_path: PathBuf) -> Result<(), CheckError> { 266 - let lexicon_source = std::fs::read_to_string(&lexicon_path).map_err(|source| { 267 - CheckError::ReadFile { 288 + let lexicon_source = 289 + std::fs::read_to_string(&lexicon_path).map_err(|source| CheckError::ReadFile { 268 290 path: lexicon_path.display().to_string(), 269 291 source, 270 - } 271 - })?; 292 + })?; 272 293 273 - let record_source = std::fs::read_to_string(&record_path).map_err(|source| { 274 - CheckError::ReadFile { 294 + let record_source = 295 + std::fs::read_to_string(&record_path).map_err(|source| CheckError::ReadFile { 275 296 path: record_path.display().to_string(), 276 297 source, 277 - } 278 - })?; 298 + })?; 279 299 280 300 let lexicon = mlf_lang::parse_lexicon(&lexicon_source).map_err(|e| { 281 301 let diagnostic = ParseDiagnostic::new( ··· 290 310 } 291 311 })?; 292 312 293 - let record: serde_json::Value = serde_json::from_str(&record_source) 294 - .map_err(|source| CheckError::ParseJson { source })?; 313 + let record: serde_json::Value = 314 + serde_json::from_str(&record_source).map_err(|source| CheckError::ParseJson { source })?; 295 315 296 316 println!("✓ Lexicon parsed successfully"); 297 317 println!("✓ JSON record parsed successfully"); ··· 348 368 349 369 /// Extract namespace from file path relative to root directory 350 370 /// e.g., root=/project/lexicons, file=/project/lexicons/com/example/foo.mlf -> com.example.foo 351 - fn extract_namespace(file_path: &std::path::Path, root_dir: &std::path::Path) -> Result<String, CheckError> { 371 + fn extract_namespace( 372 + file_path: &std::path::Path, 373 + root_dir: &std::path::Path, 374 + ) -> Result<String, CheckError> { 352 375 // Get the canonical paths to handle . and .. correctly 353 - let file_canonical = file_path.canonicalize().map_err(|source| CheckError::ReadFile { 354 - path: file_path.display().to_string(), 355 - source, 356 - })?; 376 + let file_canonical = file_path 377 + .canonicalize() 378 + .map_err(|source| CheckError::ReadFile { 379 + path: file_path.display().to_string(), 380 + source, 381 + })?; 357 382 358 - let root_canonical = root_dir.canonicalize().map_err(|source| CheckError::ReadFile { 359 - path: root_dir.display().to_string(), 360 - source, 361 - })?; 383 + let root_canonical = root_dir 384 + .canonicalize() 385 + .map_err(|source| CheckError::ReadFile { 386 + path: root_dir.display().to_string(), 387 + source, 388 + })?; 362 389 363 390 // Get relative path from root to file 364 - let relative_path = file_canonical.strip_prefix(&root_canonical) 365 - .map_err(|_| CheckError::ValidationErrors { 366 - help: Some(format!( 367 - "File {} is not within root directory {}", 368 - file_path.display(), 369 - root_dir.display() 370 - )), 371 - })?; 391 + let relative_path = 392 + file_canonical 393 + .strip_prefix(&root_canonical) 394 + .map_err(|_| CheckError::ValidationErrors { 395 + help: Some(format!( 396 + "File {} is not within root directory {}", 397 + file_path.display(), 398 + root_dir.display() 399 + )), 400 + })?; 372 401 373 402 // Convert path to namespace 374 403 let mut components = Vec::new(); ··· 389 418 390 419 if components.is_empty() { 391 420 return Err(CheckError::ValidationErrors { 392 - help: Some(format!("Could not extract namespace from path: {}", file_path.display())), 421 + help: Some(format!( 422 + "Could not extract namespace from path: {}", 423 + file_path.display() 424 + )), 393 425 }); 394 426 } 395 427
+28 -10
mlf-cli/src/config.rs
··· 29 29 30 30 #[derive(Debug, Serialize, Deserialize)] 31 31 pub struct SourceConfig { 32 - #[serde(default = "default_source_directory", skip_serializing_if = "is_default_source_directory")] 32 + #[serde( 33 + default = "default_source_directory", 34 + skip_serializing_if = "is_default_source_directory" 35 + )] 33 36 pub directory: String, 34 37 } 35 38 ··· 60 63 #[serde(default)] 61 64 pub dependencies: Vec<String>, 62 65 63 - #[serde(default = "default_allow_transitive_deps", skip_serializing_if = "is_default_allow_transitive_deps")] 66 + #[serde( 67 + default = "default_allow_transitive_deps", 68 + skip_serializing_if = "is_default_allow_transitive_deps" 69 + )] 64 70 pub allow_transitive_deps: bool, 65 71 66 - #[serde(default = "default_optimize_transitive_fetches", skip_serializing_if = "is_default_optimize_transitive_fetches")] 72 + #[serde( 73 + default = "default_optimize_transitive_fetches", 74 + skip_serializing_if = "is_default_optimize_transitive_fetches" 75 + )] 67 76 pub optimize_transitive_fetches: bool, 68 77 } 69 78 ··· 220 229 Ok(()) 221 230 } 222 231 223 - pub fn add_lexicon(&mut self, nsid: String, did: String, checksum: String, dependencies: Vec<String>) { 224 - self.lexicons.insert(nsid.clone(), LockedLexicon { 225 - nsid, 226 - did, 227 - checksum, 228 - dependencies, 229 - }); 232 + pub fn add_lexicon( 233 + &mut self, 234 + nsid: String, 235 + did: String, 236 + checksum: String, 237 + dependencies: Vec<String>, 238 + ) { 239 + self.lexicons.insert( 240 + nsid.clone(), 241 + LockedLexicon { 242 + nsid, 243 + did, 244 + checksum, 245 + dependencies, 246 + }, 247 + ); 230 248 } 231 249 } 232 250
+114 -45
mlf-cli/src/fetch.rs
··· 1 - use crate::config::{find_project_root, get_mlf_cache_dir, init_mlf_cache, ConfigError, MlfConfig, LockFile}; 2 - use mlf_lexicon_fetcher::{optimize_fetch_patterns, ProductionLexiconFetcher}; 1 + use crate::config::{ 2 + ConfigError, LockFile, MlfConfig, find_project_root, get_mlf_cache_dir, init_mlf_cache, 3 + }; 3 4 use miette::Diagnostic; 5 + use mlf_lexicon_fetcher::{ProductionLexiconFetcher, optimize_fetch_patterns}; 4 6 use sha2::{Digest, Sha256}; 5 7 use std::collections::HashSet; 6 8 use thiserror::Error; ··· 36 38 InvalidNsid(String), 37 39 } 38 40 39 - 40 - 41 41 /// Main entry point for fetch command 42 - pub async fn run_fetch(nsid: Option<String>, save: bool, update: bool, locked: bool) -> Result<(), FetchError> { 42 + pub async fn run_fetch( 43 + nsid: Option<String>, 44 + save: bool, 45 + update: bool, 46 + locked: bool, 47 + ) -> Result<(), FetchError> { 43 48 // Validate flags 44 49 if update && locked { 45 50 return Err(FetchError::HttpError( 46 - "Cannot use --update and --locked together".to_string() 51 + "Cannot use --update and --locked together".to_string(), 47 52 )); 48 53 } 49 54 ··· 69 74 fetch_transitive_dependencies( 70 75 &project_root, 71 76 &mut lockfile, 72 - config.dependencies.optimize_transitive_fetches 73 - ).await?; 77 + config.dependencies.optimize_transitive_fetches, 78 + ) 79 + .await?; 74 80 } 75 81 76 82 // Save lockfile 77 - lockfile.save(&lockfile_path).map_err(FetchError::NoProjectRoot)?; 83 + lockfile 84 + .save(&lockfile_path) 85 + .map_err(FetchError::NoProjectRoot)?; 78 86 println!("\n→ Updated mlf-lock.toml"); 79 87 80 88 // Save to mlf.toml if --save flag is provided ··· 117 125 } 118 126 } 119 127 120 - async fn fetch_all_dependencies(project_root: &std::path::Path, update: bool, locked: bool) -> Result<(), FetchError> { 128 + async fn fetch_all_dependencies( 129 + project_root: &std::path::Path, 130 + update: bool, 131 + locked: bool, 132 + ) -> Result<(), FetchError> { 121 133 // Load mlf.toml 122 134 let config_path = project_root.join("mlf.toml"); 123 135 let config = MlfConfig::load(&config_path).map_err(FetchError::NoProjectRoot)?; ··· 138 150 if locked { 139 151 if !has_existing_lockfile { 140 152 return Err(FetchError::HttpError( 141 - "No lockfile found. Run `mlf fetch` first to create mlf-lock.toml".to_string() 153 + "No lockfile found. Run `mlf fetch` first to create mlf-lock.toml".to_string(), 142 154 )); 143 155 } 144 156 ··· 157 169 "fresh" 158 170 }; 159 171 160 - println!("Fetching {} dependencies... (mode: {}, transitive deps: {})", 161 - config.dependencies.dependencies.len(), 162 - mode, 163 - if allow_transitive { "enabled" } else { "disabled" }); 172 + println!( 173 + "Fetching {} dependencies... (mode: {}, transitive deps: {})", 174 + config.dependencies.dependencies.len(), 175 + mode, 176 + if allow_transitive { 177 + "enabled" 178 + } else { 179 + "disabled" 180 + } 181 + ); 164 182 165 183 // In update mode or if no lockfile, do full fetch 166 184 // In normal mode with lockfile, use lockfile for cached entries ··· 190 208 191 209 // If transitive dependencies are enabled, fetch them 192 210 if allow_transitive { 193 - fetch_transitive_dependencies(&project_root, &mut lockfile, config.dependencies.optimize_transitive_fetches).await?; 211 + fetch_transitive_dependencies( 212 + &project_root, 213 + &mut lockfile, 214 + config.dependencies.optimize_transitive_fetches, 215 + ) 216 + .await?; 194 217 } 195 218 196 219 // Save the lockfile 197 - lockfile.save(&lockfile_path).map_err(FetchError::NoProjectRoot)?; 220 + lockfile 221 + .save(&lockfile_path) 222 + .map_err(FetchError::NoProjectRoot)?; 198 223 println!("\n→ Updated mlf-lock.toml"); 199 224 200 225 if !errors.is_empty() { ··· 212 237 ))); 213 238 } 214 239 215 - println!("\n✓ Successfully fetched all {} dependencies", success_count); 240 + println!( 241 + "\n✓ Successfully fetched all {} dependencies", 242 + success_count 243 + ); 216 244 Ok(()) 217 245 } 218 246 ··· 220 248 async fn fetch_transitive_dependencies( 221 249 project_root: &std::path::Path, 222 250 lockfile: &mut LockFile, 223 - optimize_fetches: bool 251 + optimize_fetches: bool, 224 252 ) -> Result<(), FetchError> { 225 253 let mut fetched_nsids = HashSet::new(); 226 254 // Track NSIDs from lockfile as already fetched ··· 261 289 // Optimize the fetch patterns to reduce number of fetches 262 290 let optimized_patterns = optimize_fetch_patterns(&new_deps); 263 291 264 - println!("\n→ Found {} unresolved reference(s), fetching {} optimized pattern(s)...", 265 - new_deps.len(), optimized_patterns.len()); 292 + println!( 293 + "\n→ Found {} unresolved reference(s), fetching {} optimized pattern(s)...", 294 + new_deps.len(), 295 + optimized_patterns.len() 296 + ); 266 297 267 298 // Track which patterns are wildcards and their constituent NSIDs 268 299 let mut wildcard_failures: Vec<(String, Vec<String>)> = Vec::new(); ··· 280 311 // If this was a wildcard that failed, collect the individual NSIDs for retry 281 312 if is_wildcard { 282 313 let pattern_prefix = pattern.strip_suffix(".*").unwrap(); 283 - let matching_nsids: Vec<String> = new_deps.iter() 314 + let matching_nsids: Vec<String> = new_deps 315 + .iter() 284 316 .filter(|nsid| nsid.starts_with(pattern_prefix)) 285 317 .cloned() 286 318 .collect(); ··· 312 344 match fetch_lexicon_with_lock(&broader, project_root, lockfile).await { 313 345 Ok(()) => continue, 314 346 Err(e) => { 315 - eprintln!(" Warning: broader pattern {} also failed: {}", broader, e); 347 + eprintln!( 348 + " Warning: broader pattern {} also failed: {}", 349 + broader, e 350 + ); 316 351 } 317 352 } 318 353 } ··· 322 357 } 323 358 324 359 if !still_failing.is_empty() { 325 - println!("\n→ Falling back to individual NSID fetches for patterns that couldn't be broadened..."); 360 + println!( 361 + "\n→ Falling back to individual NSID fetches for patterns that couldn't be broadened..." 362 + ); 326 363 for (failed_pattern, nsids) in still_failing { 327 - println!(" Retrying {} NSIDs from failed pattern: {}", nsids.len(), failed_pattern); 364 + println!( 365 + " Retrying {} NSIDs from failed pattern: {}", 366 + nsids.len(), 367 + failed_pattern 368 + ); 328 369 329 370 for nsid in nsids { 330 371 if !fetched_nsids.contains(&nsid) { ··· 344 385 } 345 386 } else { 346 387 // Fetch individually without optimization (safer, more predictable) 347 - println!("\n→ Found {} unresolved reference(s), fetching individually...", 348 - new_deps.len()); 388 + println!( 389 + "\n→ Found {} unresolved reference(s), fetching individually...", 390 + new_deps.len() 391 + ); 349 392 350 393 for nsid in &new_deps { 351 394 println!("\nFetching transitive dependency: {}", nsid); ··· 367 410 368 411 /// Fetch dependencies using the lockfile 369 412 /// This refetches each lexicon from its recorded DID and verifies the checksum 370 - async fn fetch_from_lockfile(project_root: &std::path::Path, lockfile: &LockFile) -> Result<(), FetchError> { 413 + async fn fetch_from_lockfile( 414 + project_root: &std::path::Path, 415 + lockfile: &LockFile, 416 + ) -> Result<(), FetchError> { 371 417 if lockfile.lexicons.is_empty() { 372 418 println!("Lockfile is empty"); 373 419 return Ok(()); 374 420 } 375 421 376 - println!("Fetching {} lexicon(s) from lockfile...", lockfile.lexicons.len()); 422 + println!( 423 + "Fetching {} lexicon(s) from lockfile...", 424 + lockfile.lexicons.len() 425 + ); 377 426 378 427 let mut errors = Vec::new(); 379 428 let mut success_count = 0; ··· 506 555 } 507 556 508 557 config.dependencies.dependencies.push(nsid.to_string()); 509 - config.save(&config_path).map_err(FetchError::NoProjectRoot)?; 558 + config 559 + .save(&config_path) 560 + .map_err(FetchError::NoProjectRoot)?; 510 561 511 562 println!("Added '{}' to dependencies in mlf.toml", nsid); 512 563 Ok(()) 513 564 } 514 565 515 - async fn fetch_lexicon_with_lock(nsid: &str, project_root: &std::path::Path, lockfile: &mut LockFile) -> Result<(), FetchError> { 566 + async fn fetch_lexicon_with_lock( 567 + nsid: &str, 568 + project_root: &std::path::Path, 569 + lockfile: &mut LockFile, 570 + ) -> Result<(), FetchError> { 516 571 // Initialize .mlf directory 517 572 init_mlf_cache(project_root).map_err(FetchError::InitFailed)?; 518 573 let mlf_dir = get_mlf_cache_dir(&project_root); ··· 585 640 let dependencies = extract_dependencies_from_json(&fetched.lexicon); 586 641 587 642 // Update lockfile with DID from fetcher metadata 588 - lockfile.add_lexicon(fetched.nsid.clone(), fetched.did.clone(), hash, dependencies); 643 + lockfile.add_lexicon( 644 + fetched.nsid.clone(), 645 + fetched.did.clone(), 646 + hash, 647 + dependencies, 648 + ); 589 649 } 590 650 591 - println!("✓ Successfully fetched {} lexicon(s) for {}", result.lexicons.len(), nsid); 651 + println!( 652 + "✓ Successfully fetched {} lexicon(s) for {}", 653 + result.lexicons.len(), 654 + nsid 655 + ); 592 656 Ok(()) 593 657 } 594 658 ··· 613 677 Ok(()) 614 678 } 615 679 616 - 617 680 /// Calculate SHA-256 hash of content 618 681 fn calculate_hash(content: &str) -> String { 619 682 let mut hasher = Sha256::new(); ··· 661 724 662 725 /// Extract external references from MLF files that need to be resolved 663 726 /// Returns a set of namespace patterns (not full NSIDs) that need to be fetched 664 - fn collect_unresolved_references(project_root: &std::path::Path) -> Result<HashSet<String>, FetchError> { 727 + fn collect_unresolved_references( 728 + project_root: &std::path::Path, 729 + ) -> Result<HashSet<String>, FetchError> { 665 730 use mlf_lang::{parser, workspace::Workspace}; 666 731 667 732 let mlf_dir = get_mlf_cache_dir(project_root); ··· 672 737 } 673 738 674 739 // Build a workspace with std library to avoid fetching std types 675 - let mut workspace = Workspace::with_std() 676 - .map_err(|e| FetchError::IoError(std::io::Error::new( 740 + let mut workspace = Workspace::with_std().map_err(|e| { 741 + FetchError::IoError(std::io::Error::new( 677 742 std::io::ErrorKind::Other, 678 - format!("Failed to load standard library: {:?}", e) 679 - )))?; 743 + format!("Failed to load standard library: {:?}", e), 744 + )) 745 + })?; 680 746 let mut unresolved = HashSet::new(); 681 747 682 748 // Recursively find all .mlf files 683 - fn collect_mlf_files(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) -> std::io::Result<()> { 749 + fn collect_mlf_files( 750 + dir: &std::path::Path, 751 + files: &mut Vec<std::path::PathBuf>, 752 + ) -> std::io::Result<()> { 684 753 if dir.is_dir() { 685 754 for entry in std::fs::read_dir(dir)? { 686 755 let entry = entry?; ··· 704 773 705 774 // Extract namespace from file path 706 775 // e.g., ".mlf/lexicons/mlf/place/stream/key.mlf" -> "place.stream.key" 707 - let relative_path = mlf_file.strip_prefix(&mlf_lexicons_dir) 708 - .map_err(|_| FetchError::IoError(std::io::Error::new( 776 + let relative_path = mlf_file.strip_prefix(&mlf_lexicons_dir).map_err(|_| { 777 + FetchError::IoError(std::io::Error::new( 709 778 std::io::ErrorKind::Other, 710 - "Failed to compute relative path" 711 - )))?; 779 + "Failed to compute relative path", 780 + )) 781 + })?; 712 782 713 783 let namespace = relative_path 714 784 .with_extension("") ··· 827 897 assert_eq!(broaden_pattern("app.rocksky.playlist"), None); 828 898 } 829 899 } 830 -
+44 -34
mlf-cli/src/generate/code.rs
··· 49 49 50 50 // Load mlf.toml if available 51 51 let project_root = crate::config::find_project_root(&current_dir).ok(); 52 - let config = project_root 53 - .as_ref() 54 - .and_then(|root| { 55 - let config_path = root.join("mlf.toml"); 56 - crate::config::MlfConfig::load(&config_path).ok() 57 - }); 52 + let config = project_root.as_ref().and_then(|root| { 53 + let config_path = root.join("mlf.toml"); 54 + crate::config::MlfConfig::load(&config_path).ok() 55 + }); 58 56 59 57 // Determine generator name 60 58 let generator_name = if let Some(explicit) = generator_name { ··· 89 87 } 90 88 })?; 91 89 92 - println!("Using generator: {} ({})", generator.name(), generator.description()); 90 + println!( 91 + "Using generator: {} ({})", 92 + generator.name(), 93 + generator.description() 94 + ); 93 95 println!("Output extension: {}\n", generator.file_extension()); 94 96 95 97 // Determine output directory ··· 105 107 path: "mlf.toml".to_string(), 106 108 source: std::io::Error::new( 107 109 std::io::ErrorKind::NotFound, 108 - format!("No output configured for generator '{}' in mlf.toml", generator_name) 110 + format!( 111 + "No output configured for generator '{}' in mlf.toml", 112 + generator_name 113 + ), 109 114 ), 110 115 })? 111 116 } else { ··· 113 118 path: "mlf.toml".to_string(), 114 119 source: std::io::Error::new( 115 120 std::io::ErrorKind::NotFound, 116 - "No mlf.toml found and no --output flag provided" 121 + "No mlf.toml found and no --output flag provided", 117 122 ), 118 123 }); 119 124 }; ··· 136 141 path: "input".to_string(), 137 142 source: std::io::Error::new( 138 143 std::io::ErrorKind::NotFound, 139 - "No input files specified and no mlf.toml found" 144 + "No input files specified and no mlf.toml found", 140 145 ), 141 146 }); 142 147 } ··· 198 203 .ok() 199 204 .map(|root| crate::config::get_mlf_cache_dir(&root)); 200 205 201 - let mut workspace = match crate::workspace_ext::workspace_with_std_and_cache(mlf_cache_dir.as_deref()) { 202 - Ok(ws) => ws, 203 - Err(e) => { 204 - errors.push(( 205 - file_path.display().to_string(), 206 - format!("Failed to load workspace: {}", e), 207 - )); 208 - continue; 209 - } 210 - }; 206 + let mut workspace = 207 + match crate::workspace_ext::workspace_with_std_and_cache(mlf_cache_dir.as_deref()) { 208 + Ok(ws) => ws, 209 + Err(e) => { 210 + errors.push(( 211 + file_path.display().to_string(), 212 + format!("Failed to load workspace: {}", e), 213 + )); 214 + continue; 215 + } 216 + }; 211 217 212 218 // Add the module to the workspace 213 219 if let Err(e) = workspace.add_module(namespace.clone(), lexicon.clone()) { ··· 237 243 let generated_code = match generator.generate(&ctx) { 238 244 Ok(code) => code, 239 245 Err(e) => { 240 - errors.push((file_path.display().to_string(), format!("Generation error: {}", e))); 246 + errors.push(( 247 + file_path.display().to_string(), 248 + format!("Generation error: {}", e), 249 + )); 241 250 continue; 242 251 } 243 252 }; ··· 326 335 let root_canonical = root_dir.canonicalize()?; 327 336 328 337 // Get the relative path from root to file 329 - let relative_path = file_canonical 330 - .strip_prefix(&root_canonical) 331 - .map_err(|_| { 332 - std::io::Error::new( 333 - std::io::ErrorKind::Other, 334 - format!( 335 - "File path {} is not under root directory {}", 336 - file_path.display(), 337 - root_dir.display() 338 - ), 339 - ) 340 - })?; 338 + let relative_path = file_canonical.strip_prefix(&root_canonical).map_err(|_| { 339 + std::io::Error::new( 340 + std::io::ErrorKind::Other, 341 + format!( 342 + "File path {} is not under root directory {}", 343 + file_path.display(), 344 + root_dir.display() 345 + ), 346 + ) 347 + })?; 341 348 342 349 // Convert path components to namespace parts 343 350 let mut namespace_parts = Vec::new(); ··· 363 370 if namespace_parts.is_empty() { 364 371 return Err(std::io::Error::new( 365 372 std::io::ErrorKind::Other, 366 - format!("Could not extract namespace from path: {}", file_path.display()), 373 + format!( 374 + "Could not extract namespace from path: {}", 375 + file_path.display() 376 + ), 367 377 )); 368 378 } 369 379
+73 -43
mlf-cli/src/generate/lexicon.rs
··· 28 28 #[source] 29 29 source: std::io::Error, 30 30 }, 31 - 32 31 } 33 32 34 - pub fn run(input_paths: Vec<PathBuf>, output_dir: Option<PathBuf>, explicit_root: Option<PathBuf>, flat: bool) -> Result<(), GenerateError> { 35 - let current_dir = std::env::current_dir() 36 - .map_err(|e| GenerateError::WriteOutput { 37 - path: ".".to_string(), 38 - source: e, 39 - })?; 33 + pub fn run( 34 + input_paths: Vec<PathBuf>, 35 + output_dir: Option<PathBuf>, 36 + explicit_root: Option<PathBuf>, 37 + flat: bool, 38 + ) -> Result<(), GenerateError> { 39 + let current_dir = std::env::current_dir().map_err(|e| GenerateError::WriteOutput { 40 + path: ".".to_string(), 41 + source: e, 42 + })?; 40 43 41 44 // Load mlf.toml if available 42 45 let project_root = crate::config::find_project_root(&current_dir).ok(); 43 - let config = project_root 44 - .as_ref() 45 - .and_then(|root| { 46 - let config_path = root.join("mlf.toml"); 47 - crate::config::MlfConfig::load(&config_path).ok() 48 - }); 46 + let config = project_root.as_ref().and_then(|root| { 47 + let config_path = root.join("mlf.toml"); 48 + crate::config::MlfConfig::load(&config_path).ok() 49 + }); 49 50 50 51 // Determine output directory 51 52 let output_dir = if let Some(explicit) = output_dir { ··· 123 124 let source = match std::fs::read_to_string(&file_path) { 124 125 Ok(s) => s, 125 126 Err(source) => { 126 - errors.push((file_path.display().to_string(), format!("Failed to read file: {}", source))); 127 + errors.push(( 128 + file_path.display().to_string(), 129 + format!("Failed to read file: {}", source), 130 + )); 127 131 continue; 128 132 } 129 133 }; ··· 143 147 .ok() 144 148 .map(|root| crate::config::get_mlf_cache_dir(&root)); 145 149 146 - let mut workspace = match crate::workspace_ext::workspace_with_std_and_cache(mlf_cache_dir.as_deref()) { 147 - Ok(ws) => ws, 148 - Err(e) => { 149 - errors.push((file_path.display().to_string(), format!("Failed to load workspace: {}", e))); 150 - continue; 151 - } 152 - }; 150 + let mut workspace = 151 + match crate::workspace_ext::workspace_with_std_and_cache(mlf_cache_dir.as_deref()) { 152 + Ok(ws) => ws, 153 + Err(e) => { 154 + errors.push(( 155 + file_path.display().to_string(), 156 + format!("Failed to load workspace: {}", e), 157 + )); 158 + continue; 159 + } 160 + }; 153 161 154 162 // Add the module to the workspace 155 163 if let Err(e) = workspace.add_module(namespace.clone(), lexicon.clone()) { 156 - errors.push((file_path.display().to_string(), format!("Failed to add module: {:?}", e))); 164 + errors.push(( 165 + file_path.display().to_string(), 166 + format!("Failed to add module: {:?}", e), 167 + )); 157 168 continue; 158 169 } 159 170 160 171 // Resolve types 161 172 if let Err(e) = workspace.resolve() { 162 - errors.push((file_path.display().to_string(), format!("Type resolution error: {:?}", e))); 173 + errors.push(( 174 + file_path.display().to_string(), 175 + format!("Type resolution error: {:?}", e), 176 + )); 163 177 continue; 164 178 } 165 179 ··· 177 191 path.push(segment); 178 192 } 179 193 if let Err(source) = std::fs::create_dir_all(&path.parent().unwrap()) { 180 - errors.push((file_path.display().to_string(), format!("Failed to create directory: {}", source))); 194 + errors.push(( 195 + file_path.display().to_string(), 196 + format!("Failed to create directory: {}", source), 197 + )); 181 198 continue; 182 199 } 183 200 path.set_extension("json"); ··· 186 203 187 204 let json_str = serde_json::to_string_pretty(&output.json).unwrap(); 188 205 if let Err(source) = std::fs::write(&output_path, format!("{}\n", json_str)) { 189 - errors.push((output_path.display().to_string(), format!("Failed to write file: {}", source))); 206 + errors.push(( 207 + output_path.display().to_string(), 208 + format!("Failed to write file: {}", source), 209 + )); 190 210 continue; 191 211 } 192 212 ··· 195 215 } 196 216 197 217 if !errors.is_empty() { 198 - eprintln!("\n{} file(s) generated successfully, {} error(s) encountered:\n", success_count, errors.len()); 218 + eprintln!( 219 + "\n{} file(s) generated successfully, {} error(s) encountered:\n", 220 + success_count, 221 + errors.len() 222 + ); 199 223 for (path, error) in &errors { 200 224 eprintln!(" {} - {}", path, error); 201 225 } ··· 248 272 /// e.g., root=/project/lexicons, file=/project/lexicons/com/example/foo.mlf -> com.example.foo 249 273 fn extract_namespace(file_path: &Path, root_dir: &Path) -> Result<String, GenerateError> { 250 274 // Get the canonical paths to handle . and .. correctly 251 - let file_canonical = file_path.canonicalize().map_err(|source| GenerateError::ReadFile { 252 - path: file_path.display().to_string(), 253 - source, 254 - })?; 275 + let file_canonical = file_path 276 + .canonicalize() 277 + .map_err(|source| GenerateError::ReadFile { 278 + path: file_path.display().to_string(), 279 + source, 280 + })?; 255 281 256 - let root_canonical = root_dir.canonicalize().map_err(|source| GenerateError::ReadFile { 257 - path: root_dir.display().to_string(), 258 - source, 259 - })?; 282 + let root_canonical = root_dir 283 + .canonicalize() 284 + .map_err(|source| GenerateError::ReadFile { 285 + path: root_dir.display().to_string(), 286 + source, 287 + })?; 260 288 261 289 // Get relative path from root to file 262 - let relative_path = file_canonical.strip_prefix(&root_canonical) 263 - .map_err(|_| GenerateError::ParseLexicon { 264 - path: file_path.display().to_string(), 265 - help: Some(format!( 266 - "File {} is not within root directory {}", 267 - file_path.display(), 268 - root_dir.display() 269 - )), 270 - })?; 290 + let relative_path = 291 + file_canonical 292 + .strip_prefix(&root_canonical) 293 + .map_err(|_| GenerateError::ParseLexicon { 294 + path: file_path.display().to_string(), 295 + help: Some(format!( 296 + "File {} is not within root directory {}", 297 + file_path.display(), 298 + root_dir.display() 299 + )), 300 + })?; 271 301 272 302 // Convert path to namespace 273 303 let mut components = Vec::new();
+204 -95
mlf-cli/src/generate/mlf.rs
··· 72 72 }, 73 73 } 74 74 75 - pub fn run(input_patterns: Vec<String>, output_dir: Option<PathBuf>, flat: bool) -> Result<(), MlfGenerateError> { 75 + pub fn run( 76 + input_patterns: Vec<String>, 77 + output_dir: Option<PathBuf>, 78 + flat: bool, 79 + ) -> Result<(), MlfGenerateError> { 76 80 let current_dir = std::env::current_dir().map_err(|source| MlfGenerateError::WriteOutput { 77 81 path: "current directory".to_string(), 78 82 source, ··· 80 84 81 85 // Load mlf.toml if available 82 86 let project_root = crate::config::find_project_root(&current_dir).ok(); 83 - let config = project_root 84 - .as_ref() 85 - .and_then(|root| { 86 - let config_path = root.join("mlf.toml"); 87 - crate::config::MlfConfig::load(&config_path).ok() 88 - }); 87 + let config = project_root.as_ref().and_then(|root| { 88 + let config_path = root.join("mlf.toml"); 89 + crate::config::MlfConfig::load(&config_path).ok() 90 + }); 89 91 90 92 // Determine output directory 91 93 let output_dir = if let Some(explicit) = output_dir { ··· 160 162 } 161 163 }; 162 164 for warning in &output.warnings { 163 - eprintln!( 164 - "warning ({}): {}", 165 - warning.namespace, warning.message 166 - ); 165 + eprintln!("warning ({}): {}", warning.namespace, warning.message); 167 166 } 168 167 let mlf_content = output.mlf; 169 168 170 169 // Extract namespace from JSON "id" field 171 - let namespace = json 172 - .get("id") 173 - .and_then(|v| v.as_str()) 174 - .ok_or_else(|| MlfGenerateError::InvalidLexicon { 170 + let namespace = json.get("id").and_then(|v| v.as_str()).ok_or_else(|| { 171 + MlfGenerateError::InvalidLexicon { 175 172 message: "Missing 'id' field in lexicon".to_string(), 176 - })?; 173 + } 174 + })?; 177 175 178 176 let output_path = if flat { 179 177 output_dir.join(format!("{}.mlf", namespace)) ··· 185 183 } 186 184 if let Err(source) = std::fs::create_dir_all(&path.parent().unwrap()) { 187 185 errors.push(( 188 - file_path.display().to_string(), 189 - format!("Failed to create directory: {}", source), 186 + file_path.display().to_string(), 187 + format!("Failed to create directory: {}", source), 190 188 )); 191 189 continue; 192 190 } ··· 228 226 pub fn generate_mlf_from_json(json: &Value) -> Result<MlfGenerateOutput, MlfGenerateError> { 229 227 let mut output = String::new(); 230 228 231 - let nsid = json 232 - .get("id") 233 - .and_then(|v| v.as_str()) 234 - .ok_or_else(|| MlfGenerateError::InvalidLexicon { 229 + let nsid = json.get("id").and_then(|v| v.as_str()).ok_or_else(|| { 230 + MlfGenerateError::InvalidLexicon { 235 231 message: "Missing 'id' field in lexicon".to_string(), 236 - })?; 232 + } 233 + })?; 237 234 238 235 let last_segment = nsid.split('.').last().unwrap_or("main"); 239 236 240 - let defs = json.get("defs").and_then(|v| v.as_object()).ok_or_else(|| { 241 - MlfGenerateError::InvalidLexicon { 237 + let defs = json 238 + .get("defs") 239 + .and_then(|v| v.as_object()) 240 + .ok_or_else(|| MlfGenerateError::InvalidLexicon { 242 241 message: "Missing or invalid 'defs' field".to_string(), 243 - } 244 - })?; 242 + })?; 245 243 246 244 let ctx = ConversionContext { 247 245 current_namespace: nsid.to_string(), ··· 295 293 /// else (e.g. `permission-set`) falls through to the unknown-def 296 294 /// passthrough. 297 295 const KNOWN_DEF_TYPES: &[&str] = &[ 298 - "record", "query", "procedure", "subscription", "token", 299 - "object", "string", "integer", "boolean", "bytes", "blob", 300 - "null", "unknown", "array", "union", "ref", "cid-link", 296 + "record", 297 + "query", 298 + "procedure", 299 + "subscription", 300 + "token", 301 + "object", 302 + "string", 303 + "integer", 304 + "boolean", 305 + "bytes", 306 + "blob", 307 + "null", 308 + "unknown", 309 + "array", 310 + "union", 311 + "ref", 312 + "cid-link", 301 313 ]; 302 314 303 315 fn is_known_def_type(type_name: &str) -> bool { ··· 309 321 /// vendor extensions (`revision`, `x-*` flags, etc.) roundtrip 310 322 /// byte-faithfully. 311 323 const RECORD_SPEC_FIELDS: &[&str] = &["type", "description", "key", "record"]; 312 - const QUERY_SPEC_FIELDS: &[&str] = &[ 313 - "type", "description", "parameters", "output", "errors", 314 - ]; 324 + const QUERY_SPEC_FIELDS: &[&str] = &["type", "description", "parameters", "output", "errors"]; 315 325 const PROCEDURE_SPEC_FIELDS: &[&str] = &[ 316 - "type", "description", "parameters", "input", "output", "errors", 326 + "type", 327 + "description", 328 + "parameters", 329 + "input", 330 + "output", 331 + "errors", 317 332 ]; 318 - const SUBSCRIPTION_SPEC_FIELDS: &[&str] = &[ 319 - "type", "description", "parameters", "message", "errors", 320 - ]; 333 + const SUBSCRIPTION_SPEC_FIELDS: &[&str] = 334 + &["type", "description", "parameters", "message", "errors"]; 321 335 const TOKEN_SPEC_FIELDS: &[&str] = &["type", "description"]; 322 336 323 337 /// Spec-defined fields at the top of a def-type definition. Covers ··· 325 339 /// union, ref) and unifies them all — anything outside this list on a 326 340 /// def-type JSON object is treated as an extension. 327 341 const DEF_TYPE_SPEC_FIELDS: &[&str] = &[ 328 - "type", "description", 342 + "type", 343 + "description", 329 344 // Constraint keys (mirror CONSTRAINT_KEYS). 330 - "minLength", "maxLength", "minGraphemes", "maxGraphemes", 331 - "minimum", "maximum", "format", "enum", "knownValues", 332 - "accept", "maxSize", "default", "const", 345 + "minLength", 346 + "maxLength", 347 + "minGraphemes", 348 + "maxGraphemes", 349 + "minimum", 350 + "maximum", 351 + "format", 352 + "enum", 353 + "knownValues", 354 + "accept", 355 + "maxSize", 356 + "default", 357 + "const", 333 358 // Container keys. 334 - "items", "properties", "required", "nullable", 335 - "refs", "closed", "ref", 359 + "items", 360 + "properties", 361 + "required", 362 + "nullable", 363 + "refs", 364 + "closed", 365 + "ref", 336 366 ]; 337 367 338 368 /// Build a `self {}` item from the top-level JSON, or `None` when ··· 342 372 fn render_self_item(json: &Value, ctx: &ConversionContext) -> Option<String> { 343 373 let obj = json.as_object()?; 344 374 345 - let description = obj.get("description").and_then(|v| v.as_str()).unwrap_or(""); 375 + let description = obj 376 + .get("description") 377 + .and_then(|v| v.as_str()) 378 + .unwrap_or(""); 346 379 let has_extension = obj 347 380 .keys() 348 381 .any(|k| !TOP_LEVEL_SPEC_FIELDS.contains(&k.as_str())); ··· 545 578 546 579 /// Reserved words in MLF that need to be escaped 547 580 const RESERVED_WORDS: &[&str] = &[ 548 - "main", "record", "query", "procedure", "subscription", "token", "def", "type", "use", 549 - "pub", "alias", "namespace", "constrained", "error", "unit", "null", "boolean", 550 - "integer", "string", "bytes", "blob", "unknown", "array", "object", "union", "ref", 581 + "main", 582 + "record", 583 + "query", 584 + "procedure", 585 + "subscription", 586 + "token", 587 + "def", 588 + "type", 589 + "use", 590 + "pub", 591 + "alias", 592 + "namespace", 593 + "constrained", 594 + "error", 595 + "unit", 596 + "null", 597 + "boolean", 598 + "integer", 599 + "string", 600 + "bytes", 601 + "blob", 602 + "unknown", 603 + "array", 604 + "object", 605 + "union", 606 + "ref", 551 607 ]; 552 608 553 609 /// Escape a name if it's a reserved word ··· 559 615 } 560 616 } 561 617 562 - fn generate_record(name: &str, def: &Value, ctx: &ConversionContext) -> Result<String, MlfGenerateError> { 618 + fn generate_record( 619 + name: &str, 620 + def: &Value, 621 + ctx: &ConversionContext, 622 + ) -> Result<String, MlfGenerateError> { 563 623 let mut output = String::new(); 564 624 565 625 // Add doc comment if present ··· 594 654 output.push_str(&format!("record {} {{\n", record_name)); 595 655 596 656 // Get the record object 597 - let record_obj = def.get("record").and_then(|v| v.as_object()).ok_or_else(|| { 598 - MlfGenerateError::InvalidLexicon { 657 + let record_obj = def 658 + .get("record") 659 + .and_then(|v| v.as_object()) 660 + .ok_or_else(|| MlfGenerateError::InvalidLexicon { 599 661 message: format!("Missing 'record' field in record definition '{}'", name), 600 - } 601 - })?; 662 + })?; 602 663 603 664 let properties = record_obj 604 665 .get("properties") ··· 620 681 } 621 682 } 622 683 623 - let required_marker = if required.contains(&field_name.as_str()) { "!" } else { "" }; 684 + let required_marker = if required.contains(&field_name.as_str()) { 685 + "!" 686 + } else { 687 + "" 688 + }; 624 689 let field_type = 625 690 render_field_type(field_def, ctx, 1, nullable.contains(&field_name.as_str()))?; 626 691 let escaped_field_name = escape_name(field_name); ··· 634 699 Ok(output) 635 700 } 636 701 637 - fn generate_query(name: &str, def: &Value, ctx: &ConversionContext) -> Result<String, MlfGenerateError> { 702 + fn generate_query( 703 + name: &str, 704 + def: &Value, 705 + ctx: &ConversionContext, 706 + ) -> Result<String, MlfGenerateError> { 638 707 let mut output = String::new(); 639 708 640 709 // Add doc comment ··· 667 736 let required = params 668 737 .get("required") 669 738 .and_then(|v| v.as_array()) 670 - .map(|arr| { 671 - arr.iter() 672 - .filter_map(|v| v.as_str()) 673 - .collect::<Vec<_>>() 674 - }) 739 + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>()) 675 740 .unwrap_or_default(); 676 741 677 742 if let Some(props) = properties { ··· 692 757 result.push_str(&format!("\n /// {}\n ", desc)); 693 758 } 694 759 } 695 - result.push_str(&format!("{}{}: {}", escaped_param_name, required_marker, param_type)); 760 + result.push_str(&format!( 761 + "{}{}: {}", 762 + escaped_param_name, required_marker, param_type 763 + )); 696 764 result 697 765 }) 698 766 .collect(); ··· 731 799 Ok(output) 732 800 } 733 801 734 - fn generate_procedure(name: &str, def: &Value, ctx: &ConversionContext) -> Result<String, MlfGenerateError> { 802 + fn generate_procedure( 803 + name: &str, 804 + def: &Value, 805 + ctx: &ConversionContext, 806 + ) -> Result<String, MlfGenerateError> { 735 807 let mut output = String::new(); 736 808 737 809 // Add doc comment ··· 748 820 output.push_str("@main\n"); 749 821 } 750 822 751 - output.push_str(&render_extension_annotations(def, PROCEDURE_SPEC_FIELDS, ctx)); 823 + output.push_str(&render_extension_annotations( 824 + def, 825 + PROCEDURE_SPEC_FIELDS, 826 + ctx, 827 + )); 752 828 753 829 let procedure_name = if name == "main" { 754 830 escape_name(&ctx.local_main_name) ··· 765 841 let required = schema 766 842 .get("required") 767 843 .and_then(|v| v.as_array()) 768 - .map(|arr| { 769 - arr.iter() 770 - .filter_map(|v| v.as_str()) 771 - .collect::<Vec<_>>() 772 - }) 844 + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>()) 773 845 .unwrap_or_default(); 774 846 775 847 if let Some(props) = properties { ··· 833 905 Ok(output) 834 906 } 835 907 836 - fn generate_subscription(name: &str, def: &Value, ctx: &ConversionContext) -> Result<String, MlfGenerateError> { 908 + fn generate_subscription( 909 + name: &str, 910 + def: &Value, 911 + ctx: &ConversionContext, 912 + ) -> Result<String, MlfGenerateError> { 837 913 let mut output = String::new(); 838 914 839 915 // Add doc comment ··· 850 926 output.push_str("@main\n"); 851 927 } 852 928 853 - output.push_str(&render_extension_annotations(def, SUBSCRIPTION_SPEC_FIELDS, ctx)); 929 + output.push_str(&render_extension_annotations( 930 + def, 931 + SUBSCRIPTION_SPEC_FIELDS, 932 + ctx, 933 + )); 854 934 855 935 let subscription_name = if name == "main" { 856 936 escape_name(&ctx.local_main_name) ··· 866 946 let required = params 867 947 .get("required") 868 948 .and_then(|v| v.as_array()) 869 - .map(|arr| { 870 - arr.iter() 871 - .filter_map(|v| v.as_str()) 872 - .collect::<Vec<_>>() 873 - }) 949 + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>()) 874 950 .unwrap_or_default(); 875 951 876 952 if let Some(props) = properties { ··· 930 1006 Ok(output) 931 1007 } 932 1008 933 - fn generate_def_type(name: &str, def: &Value, ctx: &ConversionContext) -> Result<String, MlfGenerateError> { 1009 + fn generate_def_type( 1010 + name: &str, 1011 + def: &Value, 1012 + ctx: &ConversionContext, 1013 + ) -> Result<String, MlfGenerateError> { 934 1014 let mut output = String::new(); 935 1015 936 1016 // Add doc comment if present ··· 947 1027 output.push_str("@main\n"); 948 1028 } 949 1029 950 - output.push_str(&render_extension_annotations(def, DEF_TYPE_SPEC_FIELDS, ctx)); 1030 + output.push_str(&render_extension_annotations( 1031 + def, 1032 + DEF_TYPE_SPEC_FIELDS, 1033 + ctx, 1034 + )); 951 1035 952 1036 // Use last segment of NSID for "main" definitions 953 1037 // Keywords are now allowed by the parser, so just escape with backticks ··· 1063 1147 1064 1148 impl Rendered { 1065 1149 fn atom(text: impl Into<String>) -> Self { 1066 - Self { text: text.into(), shape: Shape::Atom } 1150 + Self { 1151 + text: text.into(), 1152 + shape: Shape::Atom, 1153 + } 1067 1154 } 1068 1155 1069 1156 /// Render `base` plus any constraints from `type_def`. If no constraints ··· 1137 1224 // atomic. 1138 1225 match type_name { 1139 1226 Some("null") => Ok(Rendered::atom("null")), 1140 - Some("boolean") => Ok(Rendered::with_constraints("boolean", type_def, indent_level)), 1141 - Some("integer") => Ok(Rendered::with_constraints("integer", type_def, indent_level)), 1227 + Some("boolean") => Ok(Rendered::with_constraints( 1228 + "boolean", 1229 + type_def, 1230 + indent_level, 1231 + )), 1232 + Some("integer") => Ok(Rendered::with_constraints( 1233 + "integer", 1234 + type_def, 1235 + indent_level, 1236 + )), 1142 1237 Some("string") => Ok(render_string(type_def, indent_level)), 1143 1238 Some("bytes") => Ok(Rendered::with_constraints("bytes", type_def, indent_level)), 1144 1239 Some("blob") => Ok(Rendered::with_constraints("blob", type_def, indent_level)), ··· 1202 1297 ctx: &ConversionContext, 1203 1298 indent_level: usize, 1204 1299 ) -> Result<Rendered, MlfGenerateError> { 1205 - let obj = type_def.as_object().ok_or_else(|| MlfGenerateError::InvalidLexicon { 1206 - message: "Object type definition is not a JSON object".to_string(), 1207 - })?; 1300 + let obj = type_def 1301 + .as_object() 1302 + .ok_or_else(|| MlfGenerateError::InvalidLexicon { 1303 + message: "Object type definition is not a JSON object".to_string(), 1304 + })?; 1208 1305 1209 1306 // The spec lists `properties` as required on object types, but real- 1210 1307 // world lexicons (e.g. blog.pckt.richtext.facet marker defs) publish ··· 1214 1311 // lexicon isn't strictly spec-compliant. 1215 1312 let empty_map = serde_json::Map::new(); 1216 1313 let properties = match obj.get("properties") { 1217 - Some(v) => v.as_object().ok_or_else(|| MlfGenerateError::InvalidLexicon { 1218 - message: "`properties` in object type must be a JSON object".to_string(), 1219 - })?, 1314 + Some(v) => v 1315 + .as_object() 1316 + .ok_or_else(|| MlfGenerateError::InvalidLexicon { 1317 + message: "`properties` in object type must be a JSON object".to_string(), 1318 + })?, 1220 1319 None => { 1221 1320 ctx.warn( 1222 1321 "object type is missing `properties` field; \ ··· 1243 1342 } 1244 1343 } 1245 1344 } 1246 - let marker = if required.contains(&field_name.as_str()) { "!" } else { "" }; 1345 + let marker = if required.contains(&field_name.as_str()) { 1346 + "!" 1347 + } else { 1348 + "" 1349 + }; 1247 1350 let field_type = render_field_type( 1248 1351 field_def, 1249 1352 ctx, ··· 1301 1404 message: "Missing 'refs' in union type".to_string(), 1302 1405 })?; 1303 1406 1304 - let closed = type_def.get("closed").and_then(|v| v.as_bool()).unwrap_or(false); 1407 + let closed = type_def 1408 + .get("closed") 1409 + .and_then(|v| v.as_bool()) 1410 + .unwrap_or(false); 1305 1411 1306 1412 // An open union with zero refs is malformed per the ATProto spec — it 1307 1413 // names no valid types at all. Real-world lexicons (e.g. ··· 1334 1440 1335 1441 // A single-member open union renders as just that member — still an atom. 1336 1442 // Anything with a visible `|` becomes Union for postfix purposes. 1337 - let shape = if parts.len() >= 2 || closed { Shape::Union } else { Shape::Atom }; 1443 + let shape = if parts.len() >= 2 || closed { 1444 + Shape::Union 1445 + } else { 1446 + Shape::Atom 1447 + }; 1338 1448 Ok(Rendered { text, shape }) 1339 1449 } 1340 1450 1341 1451 fn render_ref(type_def: &Value, ctx: &ConversionContext) -> Result<String, MlfGenerateError> { 1342 - let ref_str = 1343 - type_def 1344 - .get("ref") 1345 - .and_then(|v| v.as_str()) 1346 - .ok_or_else(|| MlfGenerateError::InvalidLexicon { 1347 - message: "Missing 'ref' in ref type".to_string(), 1348 - })?; 1452 + let ref_str = type_def 1453 + .get("ref") 1454 + .and_then(|v| v.as_str()) 1455 + .ok_or_else(|| MlfGenerateError::InvalidLexicon { 1456 + message: "Missing 'ref' in ref type".to_string(), 1457 + })?; 1349 1458 Ok(resolve_ref_string(ref_str, ctx)) 1350 1459 } 1351 1460
+37 -21
mlf-cli/src/generate/mod.rs
··· 1 - use crate::config::{find_project_root, ConfigError, MlfConfig}; 1 + use crate::config::{ConfigError, MlfConfig, find_project_root}; 2 2 use std::path::PathBuf; 3 3 4 4 pub mod code; ··· 9 9 pub fn run_all() -> Result<(), std::io::Error> { 10 10 let current_dir = std::env::current_dir()?; 11 11 12 - let project_root = find_project_root(&current_dir) 13 - .map_err(|e| match e { 14 - ConfigError::NotFound => { 15 - std::io::Error::new( 16 - std::io::ErrorKind::NotFound, 17 - "No mlf.toml found. Please create a configuration file or provide explicit arguments." 18 - ) 19 - } 20 - _ => std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to load config: {}", e)), 21 - })?; 12 + let project_root = find_project_root(&current_dir).map_err(|e| match e { 13 + ConfigError::NotFound => std::io::Error::new( 14 + std::io::ErrorKind::NotFound, 15 + "No mlf.toml found. Please create a configuration file or provide explicit arguments.", 16 + ), 17 + _ => std::io::Error::new( 18 + std::io::ErrorKind::Other, 19 + format!("Failed to load config: {}", e), 20 + ), 21 + })?; 22 22 23 23 let config_path = project_root.join("mlf.toml"); 24 - let config = MlfConfig::load(&config_path) 25 - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to load config: {}", e)))?; 24 + let config = MlfConfig::load(&config_path).map_err(|e| { 25 + std::io::Error::new( 26 + std::io::ErrorKind::Other, 27 + format!("Failed to load config: {}", e), 28 + ) 29 + })?; 26 30 27 31 if config.output.is_empty() { 28 32 println!("No output configurations found in mlf.toml"); ··· 42 46 let output_type = &output_config.r#type; 43 47 let output_dir = PathBuf::from(&output_config.directory); 44 48 45 - println!("\nGenerating {} output to {}...", output_type, output_config.directory); 49 + println!( 50 + "\nGenerating {} output to {}...", 51 + output_type, output_config.directory 52 + ); 46 53 47 54 let result = match output_type.as_str() { 48 - "lexicon" => { 49 - lexicon::run(input_paths.clone(), Some(output_dir), Some(source_dir.clone()), false) 50 - .map_err(|e| format!("{}", e)) 51 - } 55 + "lexicon" => lexicon::run( 56 + input_paths.clone(), 57 + Some(output_dir), 58 + Some(source_dir.clone()), 59 + false, 60 + ) 61 + .map_err(|e| format!("{}", e)), 52 62 "mlf" => { 53 63 // For MLF output, we expect JSON lexicons as input 54 64 // This is a bit different - we'd need JSON input patterns ··· 57 67 } 58 68 generator_type => { 59 69 // Assume it's a code generator (typescript, go, rust, etc.) 60 - code::run(Some(generator_type.to_string()), input_paths.clone(), Some(output_dir), Some(source_dir.clone()), false) 61 - .map_err(|e| format!("{}", e)) 70 + code::run( 71 + Some(generator_type.to_string()), 72 + input_paths.clone(), 73 + Some(output_dir), 74 + Some(source_dir.clone()), 75 + false, 76 + ) 77 + .map_err(|e| format!("{}", e)) 62 78 } 63 79 }; 64 80 ··· 85 101 } 86 102 return Err(std::io::Error::new( 87 103 std::io::ErrorKind::Other, 88 - format!("Failed to generate {} output(s)", errors.len()) 104 + format!("Failed to generate {} output(s)", errors.len()), 89 105 )); 90 106 } 91 107
+1 -1
mlf-cli/src/init.rs
··· 1 - use crate::config::{init_mlf_cache, MlfConfig}; 1 + use crate::config::{MlfConfig, init_mlf_cache}; 2 2 use std::io::Write; 3 3 4 4 pub fn run_init(skip_prompts: bool) -> Result<(), std::io::Error> {
+85 -31
mlf-cli/src/main.rs
··· 31 31 }, 32 32 33 33 Check { 34 - #[arg(help = "MLF lexicon file(s) or directory to validate. If omitted, checks source directory from mlf.toml")] 34 + #[arg( 35 + help = "MLF lexicon file(s) or directory to validate. If omitted, checks source directory from mlf.toml" 36 + )] 35 37 input: Vec<PathBuf>, 36 38 37 - #[arg(long, help = "Root directory for namespace calculation (defaults to mlf.toml source directory or current directory)")] 39 + #[arg( 40 + long, 41 + help = "Root directory for namespace calculation (defaults to mlf.toml source directory or current directory)" 42 + )] 38 43 root: Option<PathBuf>, 39 44 }, 40 45 ··· 52 57 }, 53 58 54 59 Fetch { 55 - #[arg(help = "Namespace to fetch (e.g., stream.place). If omitted, fetches all dependencies from mlf.toml")] 60 + #[arg( 61 + help = "Namespace to fetch (e.g., stream.place). If omitted, fetches all dependencies from mlf.toml" 62 + )] 56 63 nsid: Option<String>, 57 64 58 65 #[arg(long, help = "Add namespace to dependencies in mlf.toml")] 59 66 save: bool, 60 67 61 - #[arg(long, help = "Update dependencies to latest versions (ignores lockfile)")] 68 + #[arg( 69 + long, 70 + help = "Update dependencies to latest versions (ignores lockfile)" 71 + )] 62 72 update: bool, 63 73 64 74 #[arg(long, help = "Require lockfile and fail if dependencies need updating")] ··· 69 79 #[derive(Subcommand)] 70 80 enum GenerateCommands { 71 81 Lexicon { 72 - #[arg(short, long, help = "Input MLF file(s) or directory. If omitted, uses source directory from mlf.toml")] 82 + #[arg( 83 + short, 84 + long, 85 + help = "Input MLF file(s) or directory. If omitted, uses source directory from mlf.toml" 86 + )] 73 87 input: Vec<PathBuf>, 74 88 75 - #[arg(short, long, help = "Output directory. If omitted, uses first lexicon output from mlf.toml")] 89 + #[arg( 90 + short, 91 + long, 92 + help = "Output directory. If omitted, uses first lexicon output from mlf.toml" 93 + )] 76 94 output: Option<PathBuf>, 77 95 78 - #[arg(long, help = "Root directory for namespace calculation (defaults to mlf.toml source directory or current directory)")] 96 + #[arg( 97 + long, 98 + help = "Root directory for namespace calculation (defaults to mlf.toml source directory or current directory)" 99 + )] 79 100 root: Option<PathBuf>, 80 101 81 102 #[arg(long, help = "Use flat file structure (e.g., app.bsky.post.json)")] 82 103 flat: bool, 83 104 }, 84 105 Code { 85 - #[arg(short, long, help = "Generator to use (typescript, go, rust, etc.). If omitted, uses first code output from mlf.toml")] 106 + #[arg( 107 + short, 108 + long, 109 + help = "Generator to use (typescript, go, rust, etc.). If omitted, uses first code output from mlf.toml" 110 + )] 86 111 generator: Option<String>, 87 112 88 - #[arg(short, long, help = "Input MLF file(s) or directory. If omitted, uses source directory from mlf.toml")] 113 + #[arg( 114 + short, 115 + long, 116 + help = "Input MLF file(s) or directory. If omitted, uses source directory from mlf.toml" 117 + )] 89 118 input: Vec<PathBuf>, 90 119 91 - #[arg(short, long, help = "Output directory. If omitted, uses matching output from mlf.toml")] 120 + #[arg( 121 + short, 122 + long, 123 + help = "Output directory. If omitted, uses matching output from mlf.toml" 124 + )] 92 125 output: Option<PathBuf>, 93 126 94 - #[arg(long, help = "Root directory for namespace calculation (defaults to mlf.toml source directory or current directory)")] 127 + #[arg( 128 + long, 129 + help = "Root directory for namespace calculation (defaults to mlf.toml source directory or current directory)" 130 + )] 95 131 root: Option<PathBuf>, 96 132 97 133 #[arg(long, help = "Use flat file structure (e.g., app.bsky.post.ts)")] 98 134 flat: bool, 99 135 }, 100 136 Mlf { 101 - #[arg(short, long, help = "Input JSON lexicon files (glob patterns supported)")] 137 + #[arg( 138 + short, 139 + long, 140 + help = "Input JSON lexicon files (glob patterns supported)" 141 + )] 102 142 input: Vec<String>, 103 143 104 - #[arg(short, long, help = "Output directory. If omitted, uses first mlf output from mlf.toml")] 144 + #[arg( 145 + short, 146 + long, 147 + help = "Output directory. If omitted, uses first mlf output from mlf.toml" 148 + )] 105 149 output: Option<PathBuf>, 106 150 107 151 #[arg(long, help = "Use flat file structure (e.g., app.bsky.post.json)")] ··· 114 158 let cli = Cli::parse(); 115 159 116 160 let result: Result<(), miette::Report> = match cli.command { 117 - Commands::Init { yes } => { 118 - init::run_init(yes).into_diagnostic() 119 - } 120 - Commands::Check { input, root } => { 121 - check::run_check(input, root).into_diagnostic() 122 - } 161 + Commands::Init { yes } => init::run_init(yes).into_diagnostic(), 162 + Commands::Check { input, root } => check::run_check(input, root).into_diagnostic(), 123 163 Commands::Validate { lexicon, record } => { 124 164 check::validate(lexicon, record).into_diagnostic() 125 165 } 126 166 Commands::Generate { command } => match command { 127 - Some(GenerateCommands::Lexicon { input, output, root, flat }) => { 128 - generate::lexicon::run(input, output, root, flat).into_diagnostic() 129 - } 130 - Some(GenerateCommands::Code { generator, input, output, root, flat }) => { 131 - generate::code::run(generator, input, output, root, flat).into_diagnostic() 132 - } 133 - Some(GenerateCommands::Mlf { input, output, flat }) => { 134 - generate::mlf::run(input, output, flat).into_diagnostic() 135 - } 167 + Some(GenerateCommands::Lexicon { 168 + input, 169 + output, 170 + root, 171 + flat, 172 + }) => generate::lexicon::run(input, output, root, flat).into_diagnostic(), 173 + Some(GenerateCommands::Code { 174 + generator, 175 + input, 176 + output, 177 + root, 178 + flat, 179 + }) => generate::code::run(generator, input, output, root, flat).into_diagnostic(), 180 + Some(GenerateCommands::Mlf { 181 + input, 182 + output, 183 + flat, 184 + }) => generate::mlf::run(input, output, flat).into_diagnostic(), 136 185 None => { 137 186 // Run all outputs from mlf.toml 138 187 generate::run_all().into_diagnostic() 139 188 } 140 189 }, 141 - Commands::Fetch { nsid, save, update, locked } => { 142 - fetch::run_fetch(nsid, save, update, locked).await.into_diagnostic() 143 - } 190 + Commands::Fetch { 191 + nsid, 192 + save, 193 + update, 194 + locked, 195 + } => fetch::run_fetch(nsid, save, update, locked) 196 + .await 197 + .into_diagnostic(), 144 198 }; 145 199 146 200 if let Err(e) = result {
+4 -8
mlf-cli/src/workspace_ext.rs
··· 77 77 .ok_or_else(|| "Non-UTF8 path".to_string())?; 78 78 79 79 // Remove .mlf extension 80 - let without_ext = path_str 81 - .strip_suffix(".mlf") 82 - .unwrap_or(path_str); 80 + let without_ext = path_str.strip_suffix(".mlf").unwrap_or(path_str); 83 81 84 82 // Replace path separators with dots 85 83 // e.g., "place/stream/key" -> "place.stream.key" ··· 89 87 } 90 88 91 89 /// Create a workspace with std library AND .mlf cache if it exists 92 - pub fn workspace_with_std_and_cache( 93 - mlf_cache_dir: Option<&Path>, 94 - ) -> Result<Workspace, String> { 90 + pub fn workspace_with_std_and_cache(mlf_cache_dir: Option<&Path>) -> Result<Workspace, String> { 95 91 // Start with std library 96 - let mut workspace = Workspace::with_std() 97 - .map_err(|e| format!("Failed to load std library: {:?}", e))?; 92 + let mut workspace = 93 + Workspace::with_std().map_err(|e| format!("Failed to load std library: {:?}", e))?; 98 94 99 95 // Load from .mlf cache if provided 100 96 if let Some(cache_dir) = mlf_cache_dir {
+2 -5
mlf-codegen/examples/all_generators.rs
··· 5 5 use mlf_codegen::plugin; 6 6 7 7 // Import all the plugin crates to trigger their registration 8 - extern crate mlf_codegen_typescript; 9 8 extern crate mlf_codegen_go; 10 9 extern crate mlf_codegen_rust; 10 + extern crate mlf_codegen_typescript; 11 11 12 12 fn main() { 13 13 println!("MLF Code Generator Plugins (All Loaded)\n"); ··· 17 17 println!("Found {} generator(s):\n", generators.len()); 18 18 19 19 for generator in generators { 20 - println!(" {} ({}):", 21 - generator.name(), 22 - generator.file_extension() 23 - ); 20 + println!(" {} ({}):", generator.name(), generator.file_extension()); 24 21 println!(" {}\n", generator.description()); 25 22 } 26 23 }
+4 -5
mlf-codegen/examples/list_generators.rs
··· 14 14 if generators.is_empty() { 15 15 println!("No generators registered!"); 16 16 println!("\nTo use plugins, depend on them in your Cargo.toml:"); 17 - println!(" mlf-codegen-typescript = {{ path = \"../codegen-plugins/mlf-codegen-typescript\" }}"); 17 + println!( 18 + " mlf-codegen-typescript = {{ path = \"../codegen-plugins/mlf-codegen-typescript\" }}" 19 + ); 18 20 println!(" mlf-codegen-go = {{ path = \"../codegen-plugins/mlf-codegen-go\" }}"); 19 21 println!(" mlf-codegen-rust = {{ path = \"../codegen-plugins/mlf-codegen-rust\" }}"); 20 22 } else { 21 23 println!("Found {} generator(s):\n", generators.len()); 22 24 23 25 for generator in generators { 24 - println!(" {} ({}):", 25 - generator.name(), 26 - generator.file_extension() 27 - ); 26 + println!(" {} ({}):", generator.name(), generator.file_extension()); 28 27 println!(" {}\n", generator.description()); 29 28 } 30 29 }
+2 -1
mlf-codegen/examples/plugin_test.rs
··· 6 6 // List all registered generators 7 7 println!("Registered generators:"); 8 8 for generator in plugin::generators() { 9 - println!(" - {} ({}): {}", 9 + println!( 10 + " - {} ({}): {}", 10 11 generator.name(), 11 12 generator.file_extension(), 12 13 generator.description()
+224 -63
mlf-codegen/src/lib.rs
··· 1 1 use mlf_lang::ast::*; 2 2 use mlf_lang::{ResolvedRef, Workspace}; 3 - use serde_json::{json, Map, Value}; 3 + use serde_json::{Map, Value, json}; 4 4 use std::collections::HashMap; 5 5 6 6 /// A non-fatal advisory emitted during codegen. Mirrors the shape of ··· 92 92 } 93 93 94 94 fn get_annotation_string_value(annotations: &[Annotation], name: &str) -> Option<String> { 95 - annotations.iter() 95 + annotations 96 + .iter() 96 97 .find(|ann| ann.name.name == name) 97 98 .and_then(|ann| { 98 99 // Get first positional argument if it exists 99 - ann.args.first().and_then(|arg| { 100 - match arg { 101 - AnnotationArg::Positional(AnnotationValue::String(s)) => Some(s.clone()), 102 - _ => None, 103 - } 100 + ann.args.first().and_then(|arg| match arg { 101 + AnnotationArg::Positional(AnnotationValue::String(s)) => Some(s.clone()), 102 + _ => None, 104 103 }) 105 104 }) 106 105 } 107 106 108 107 fn get_encoding_annotation(annotations: &[Annotation], param_name: &str) -> Option<String> { 109 - annotations.iter() 108 + annotations 109 + .iter() 110 110 .find(|ann| ann.name.name == "encoding") 111 111 .and_then(|ann| { 112 112 // First check for named argument matching param_name ··· 131 131 }) 132 132 } 133 133 134 - pub fn generate_lexicon(namespace: &str, lexicon: &Lexicon, workspace: &Workspace) -> CodegenOutput { 134 + pub fn generate_lexicon( 135 + namespace: &str, 136 + lexicon: &Lexicon, 137 + workspace: &Workspace, 138 + ) -> CodegenOutput { 135 139 let usage_counts = analyze_type_usage(lexicon); 136 140 let eligibility = MainEligibility::for_lexicon(namespace, lexicon); 137 141 ··· 144 148 match item { 145 149 Item::Record(record) => { 146 150 let mut value = generate_record_json(record, &usage_counts, workspace, namespace); 147 - apply_extension_annotations(&mut value, &record.annotations, workspace, namespace, &mut warnings); 148 - insert_def(&mut defs, &record.name.name, eligibility.is_main(&record.name.name, &record.annotations), value); 151 + apply_extension_annotations( 152 + &mut value, 153 + &record.annotations, 154 + workspace, 155 + namespace, 156 + &mut warnings, 157 + ); 158 + insert_def( 159 + &mut defs, 160 + &record.name.name, 161 + eligibility.is_main(&record.name.name, &record.annotations), 162 + value, 163 + ); 149 164 } 150 165 Item::Query(query) => { 151 166 let mut value = generate_query_json(query, &usage_counts, workspace, namespace); 152 - apply_extension_annotations(&mut value, &query.annotations, workspace, namespace, &mut warnings); 153 - insert_def(&mut defs, &query.name.name, eligibility.is_main(&query.name.name, &query.annotations), value); 167 + apply_extension_annotations( 168 + &mut value, 169 + &query.annotations, 170 + workspace, 171 + namespace, 172 + &mut warnings, 173 + ); 174 + insert_def( 175 + &mut defs, 176 + &query.name.name, 177 + eligibility.is_main(&query.name.name, &query.annotations), 178 + value, 179 + ); 154 180 } 155 181 Item::Procedure(procedure) => { 156 - let mut value = generate_procedure_json(procedure, &usage_counts, workspace, namespace); 157 - apply_extension_annotations(&mut value, &procedure.annotations, workspace, namespace, &mut warnings); 158 - insert_def(&mut defs, &procedure.name.name, eligibility.is_main(&procedure.name.name, &procedure.annotations), value); 182 + let mut value = 183 + generate_procedure_json(procedure, &usage_counts, workspace, namespace); 184 + apply_extension_annotations( 185 + &mut value, 186 + &procedure.annotations, 187 + workspace, 188 + namespace, 189 + &mut warnings, 190 + ); 191 + insert_def( 192 + &mut defs, 193 + &procedure.name.name, 194 + eligibility.is_main(&procedure.name.name, &procedure.annotations), 195 + value, 196 + ); 159 197 } 160 198 Item::Subscription(subscription) => { 161 - let mut value = generate_subscription_json(subscription, &usage_counts, workspace, namespace); 162 - apply_extension_annotations(&mut value, &subscription.annotations, workspace, namespace, &mut warnings); 163 - insert_def(&mut defs, &subscription.name.name, eligibility.is_main(&subscription.name.name, &subscription.annotations), value); 199 + let mut value = 200 + generate_subscription_json(subscription, &usage_counts, workspace, namespace); 201 + apply_extension_annotations( 202 + &mut value, 203 + &subscription.annotations, 204 + workspace, 205 + namespace, 206 + &mut warnings, 207 + ); 208 + insert_def( 209 + &mut defs, 210 + &subscription.name.name, 211 + eligibility.is_main(&subscription.name.name, &subscription.annotations), 212 + value, 213 + ); 164 214 } 165 215 Item::DefType(def_type) => { 166 - let mut value = generate_def_type_json(def_type, &usage_counts, workspace, namespace); 167 - apply_extension_annotations(&mut value, &def_type.annotations, workspace, namespace, &mut warnings); 168 - insert_def(&mut defs, &def_type.name.name, eligibility.is_main(&def_type.name.name, &def_type.annotations), value); 216 + let mut value = 217 + generate_def_type_json(def_type, &usage_counts, workspace, namespace); 218 + apply_extension_annotations( 219 + &mut value, 220 + &def_type.annotations, 221 + workspace, 222 + namespace, 223 + &mut warnings, 224 + ); 225 + insert_def( 226 + &mut defs, 227 + &def_type.name.name, 228 + eligibility.is_main(&def_type.name.name, &def_type.annotations), 229 + value, 230 + ); 169 231 } 170 232 Item::Token(token) => { 171 233 let mut token_obj = Map::new(); 172 234 token_obj.insert("type".to_string(), json!("token")); 173 235 insert_opt_str(&mut token_obj, "description", &extract_docs(&token.docs)); 174 236 let mut value = Value::Object(token_obj); 175 - apply_extension_annotations(&mut value, &token.annotations, workspace, namespace, &mut warnings); 237 + apply_extension_annotations( 238 + &mut value, 239 + &token.annotations, 240 + workspace, 241 + namespace, 242 + &mut warnings, 243 + ); 176 244 defs.insert(token.name.name.clone(), value); 177 245 } 178 246 Item::SelfItem(self_item) => { ··· 181 249 // annotations become top-level JSON fields alongside 182 250 // `lexicon`, `id`, `defs`. 183 251 self_description = extract_docs(&self_item.docs); 184 - self_extensions = collect_extension_fields(&self_item.annotations, workspace, namespace, &mut warnings); 252 + self_extensions = collect_extension_fields( 253 + &self_item.annotations, 254 + workspace, 255 + namespace, 256 + &mut warnings, 257 + ); 185 258 } 186 259 // Inline types never appear in `defs` — they expand at their point 187 260 // of use. Use statements are structural and not emitted. ··· 233 306 let Some(obj) = value.as_object_mut() else { 234 307 return; 235 308 }; 236 - for (key, field_value) in collect_extension_fields(annotations, workspace, current_namespace, warnings) { 309 + for (key, field_value) in 310 + collect_extension_fields(annotations, workspace, current_namespace, warnings) 311 + { 237 312 obj.insert(key, field_value); 238 313 } 239 314 } ··· 308 383 message: format!( 309 384 "@const({:?}, {}): ATProto's data model has no floats; \ 310 385 emitting as string {:?} to stay spec-compliant", 311 - key, n, n.to_string() 386 + key, 387 + n, 388 + n.to_string() 312 389 ), 313 390 }); 314 391 Value::String(n.to_string()) ··· 326 403 } 327 404 AnnotationValue::Boolean(b) => Value::Bool(*b), 328 405 AnnotationValue::Null => Value::Null, 329 - AnnotationValue::Array(items) => { 330 - Value::Array( 331 - items 332 - .iter() 333 - .map(|item| annotation_value_to_json(item, key, namespace, warnings)) 334 - .collect(), 335 - ) 336 - } 406 + AnnotationValue::Array(items) => Value::Array( 407 + items 408 + .iter() 409 + .map(|item| annotation_value_to_json(item, key, namespace, warnings)) 410 + .collect(), 411 + ), 337 412 AnnotationValue::Object(entries) => { 338 413 let mut obj = Map::new(); 339 414 for (entry_key, entry_value) in entries { ··· 424 499 /// Insert a def into the lexicon's `defs` map under the canonical key — 425 500 /// `"main"` for the main def, otherwise the def's own name. 426 501 fn insert_def(defs: &mut Map<String, Value>, name: &str, is_main: bool, value: Value) { 427 - let key = if is_main { "main".to_string() } else { name.to_string() }; 502 + let key = if is_main { 503 + "main".to_string() 504 + } else { 505 + name.to_string() 506 + }; 428 507 defs.insert(key, value); 429 508 } 430 509 ··· 563 642 if !param.optional { 564 643 required.push(param.name.name.clone()); 565 644 } 566 - let mut param_json = generate_type_json(&param.ty, usage_counts, workspace, current_namespace); 645 + let mut param_json = 646 + generate_type_json(&param.ty, usage_counts, workspace, current_namespace); 567 647 add_description_from_docs(&mut param_json, &param.docs); 568 648 properties.insert(param.name.name.clone(), param_json); 569 649 } ··· 598 678 if is_nullable { 599 679 nullable.push(field.name.name.clone()); 600 680 } 601 - let mut field_json = generate_type_json(&effective_ty, usage_counts, workspace, current_namespace); 681 + let mut field_json = 682 + generate_type_json(&effective_ty, usage_counts, workspace, current_namespace); 602 683 add_description_from_docs(&mut field_json, &field.docs); 603 684 properties.insert(field.name.name.clone(), field_json); 604 685 } ··· 617 698 if let Type::Parenthesized { inner, .. } = ty { 618 699 return strip_nullable(inner); 619 700 } 620 - let Type::Union { types, closed, span } = ty else { 701 + let Type::Union { 702 + types, 703 + closed, 704 + span, 705 + } = ty 706 + else { 621 707 return None; 622 708 }; 623 709 let has_null = types.iter().any(is_null_primitive); ··· 643 729 644 730 fn is_null_primitive(ty: &Type) -> bool { 645 731 match ty { 646 - Type::Primitive { kind: PrimitiveType::Null, .. } => true, 732 + Type::Primitive { 733 + kind: PrimitiveType::Null, 734 + .. 735 + } => true, 647 736 Type::Parenthesized { inner, .. } => is_null_primitive(inner), 648 737 _ => false, 649 738 } ··· 684 773 match workspace.resolve_ref(path, current_namespace) { 685 774 Some(ResolvedRef::Local { def_name }) => format!("#{}", def_name), 686 775 Some(ResolvedRef::ImplicitMain { namespace, .. }) => namespace, 687 - Some(ResolvedRef::External { namespace, def_name }) => format!("{}#{}", namespace, def_name), 776 + Some(ResolvedRef::External { 777 + namespace, 778 + def_name, 779 + }) => format!("{}#{}", namespace, def_name), 688 780 None => unresolved_ref_fallback(path), 689 781 } 690 782 } ··· 706 798 format!("{}#{}", namespace, def_name) 707 799 } 708 800 709 - fn generate_record_json(record: &Record, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 801 + fn generate_record_json( 802 + record: &Record, 803 + usage_counts: &HashMap<String, usize>, 804 + workspace: &Workspace, 805 + current_namespace: &str, 806 + ) -> Value { 710 807 let (properties, required, nullable) = 711 808 collect_object_fields(&record.fields, usage_counts, workspace, current_namespace); 712 809 ··· 717 814 record_obj.insert("properties".to_string(), Value::Object(properties)); 718 815 719 816 // Check for @key annotation, default to "tid" 720 - let key = get_annotation_string_value(&record.annotations, "key").unwrap_or_else(|| "tid".to_string()); 817 + let key = get_annotation_string_value(&record.annotations, "key") 818 + .unwrap_or_else(|| "tid".to_string()); 721 819 722 820 let mut record_top = Map::new(); 723 821 record_top.insert("type".to_string(), json!("record")); ··· 727 825 Value::Object(record_top) 728 826 } 729 827 730 - fn generate_query_json(query: &Query, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 828 + fn generate_query_json( 829 + query: &Query, 830 + usage_counts: &HashMap<String, usize>, 831 + workspace: &Workspace, 832 + current_namespace: &str, 833 + ) -> Value { 731 834 let (params_properties, params_required) = 732 835 build_param_properties(&query.params, usage_counts, workspace, current_namespace); 733 836 let params = build_params_object(params_properties, &params_required); ··· 741 844 ReturnType::Type(ty) => { 742 845 let mut output_obj = Map::new(); 743 846 output_obj.insert("encoding".to_string(), json!(output_encoding)); 744 - output_obj.insert("schema".to_string(), generate_type_json(ty, usage_counts, workspace, current_namespace)); 847 + output_obj.insert( 848 + "schema".to_string(), 849 + generate_type_json(ty, usage_counts, workspace, current_namespace), 850 + ); 745 851 (Some(Value::Object(output_obj)), None) 746 852 } 747 - ReturnType::TypeWithErrors { success, errors, .. } => { 853 + ReturnType::TypeWithErrors { 854 + success, errors, .. 855 + } => { 748 856 let mut error_array = Vec::new(); 749 857 for error in errors { 750 858 let error_docs = extract_docs(&error.docs); ··· 761 869 762 870 let mut output_obj = Map::new(); 763 871 output_obj.insert("encoding".to_string(), json!(output_encoding)); 764 - output_obj.insert("schema".to_string(), generate_type_json(success, usage_counts, workspace, current_namespace)); 765 - (Some(Value::Object(output_obj)), Some(Value::Array(error_array))) 872 + output_obj.insert( 873 + "schema".to_string(), 874 + generate_type_json(success, usage_counts, workspace, current_namespace), 875 + ); 876 + ( 877 + Some(Value::Object(output_obj)), 878 + Some(Value::Array(error_array)), 879 + ) 766 880 } 767 881 }; 768 882 ··· 779 893 Value::Object(query_obj) 780 894 } 781 895 782 - fn generate_procedure_json(procedure: &Procedure, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 783 - let (params_properties, params_required) = 784 - build_param_properties(&procedure.params, usage_counts, workspace, current_namespace); 896 + fn generate_procedure_json( 897 + procedure: &Procedure, 898 + usage_counts: &HashMap<String, usize>, 899 + workspace: &Workspace, 900 + current_namespace: &str, 901 + ) -> Value { 902 + let (params_properties, params_required) = build_param_properties( 903 + &procedure.params, 904 + usage_counts, 905 + workspace, 906 + current_namespace, 907 + ); 785 908 786 909 // Check for @encoding annotation with "input" parameter, default to "application/json" 787 910 let input_encoding = get_encoding_annotation(&procedure.annotations, "input") ··· 810 933 ReturnType::Type(ty) => { 811 934 let mut output_obj = Map::new(); 812 935 output_obj.insert("encoding".to_string(), json!(output_encoding)); 813 - output_obj.insert("schema".to_string(), generate_type_json(ty, usage_counts, workspace, current_namespace)); 936 + output_obj.insert( 937 + "schema".to_string(), 938 + generate_type_json(ty, usage_counts, workspace, current_namespace), 939 + ); 814 940 (Some(Value::Object(output_obj)), None) 815 941 } 816 - ReturnType::TypeWithErrors { success, errors, .. } => { 942 + ReturnType::TypeWithErrors { 943 + success, errors, .. 944 + } => { 817 945 let mut error_array = Vec::new(); 818 946 for error in errors { 819 947 let error_docs = extract_docs(&error.docs); ··· 830 958 831 959 let mut output_obj = Map::new(); 832 960 output_obj.insert("encoding".to_string(), json!(output_encoding)); 833 - output_obj.insert("schema".to_string(), generate_type_json(success, usage_counts, workspace, current_namespace)); 834 - (Some(Value::Object(output_obj)), Some(Value::Array(error_array))) 961 + output_obj.insert( 962 + "schema".to_string(), 963 + generate_type_json(success, usage_counts, workspace, current_namespace), 964 + ); 965 + ( 966 + Some(Value::Object(output_obj)), 967 + Some(Value::Array(error_array)), 968 + ) 835 969 } 836 970 }; 837 971 ··· 856 990 workspace: &Workspace, 857 991 current_namespace: &str, 858 992 ) -> Value { 859 - let (params_properties, params_required) = 860 - build_param_properties(&subscription.params, usage_counts, workspace, current_namespace); 993 + let (params_properties, params_required) = build_param_properties( 994 + &subscription.params, 995 + usage_counts, 996 + workspace, 997 + current_namespace, 998 + ); 861 999 862 1000 let mut result = Map::new(); 863 1001 result.insert("type".to_string(), json!("subscription")); 864 - insert_opt_str(&mut result, "description", &extract_docs(&subscription.docs)); 1002 + insert_opt_str( 1003 + &mut result, 1004 + "description", 1005 + &extract_docs(&subscription.docs), 1006 + ); 865 1007 let mut result = Value::Object(result); 866 1008 867 1009 if let Some(messages) = &subscription.messages { ··· 878 1020 result 879 1021 } 880 1022 881 - fn generate_def_type_json(def_type: &DefType, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 882 - let mut field_json = generate_type_json(&def_type.ty, usage_counts, workspace, current_namespace); 1023 + fn generate_def_type_json( 1024 + def_type: &DefType, 1025 + usage_counts: &HashMap<String, usize>, 1026 + workspace: &Workspace, 1027 + current_namespace: &str, 1028 + ) -> Value { 1029 + let mut field_json = 1030 + generate_type_json(&def_type.ty, usage_counts, workspace, current_namespace); 883 1031 // def types insert `description` at position 1 (right after `type`) to 884 1032 // match the canonical field order in published lexicons. 885 1033 let text = extract_docs(&def_type.docs); ··· 891 1039 field_json 892 1040 } 893 1041 894 - fn generate_type_json(ty: &Type, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 1042 + fn generate_type_json( 1043 + ty: &Type, 1044 + usage_counts: &HashMap<String, usize>, 1045 + workspace: &Workspace, 1046 + current_namespace: &str, 1047 + ) -> Value { 895 1048 match ty { 896 1049 Type::Primitive { kind, .. } => generate_primitive_json(*kind), 897 1050 Type::Reference { path, .. } => { 898 1051 // Inline types expand in place; other references resolve to an NSID. 899 1052 if workspace.is_inline_type(path) { 900 1053 if let Some(resolved_ty) = workspace.resolve_type_reference(path) { 901 - return generate_type_json(&resolved_ty, usage_counts, workspace, current_namespace); 1054 + return generate_type_json( 1055 + &resolved_ty, 1056 + usage_counts, 1057 + workspace, 1058 + current_namespace, 1059 + ); 902 1060 } 903 1061 } 904 1062 let nsid = resolve_ref_nsid(path, workspace, current_namespace); ··· 951 1109 // Parentheses are just for grouping - unwrap and process inner type 952 1110 generate_type_json(inner, usage_counts, workspace, current_namespace) 953 1111 } 954 - Type::Constrained { base, constraints, .. } => { 955 - let mut base_json = generate_type_json(base, usage_counts, workspace, current_namespace); 1112 + Type::Constrained { 1113 + base, constraints, .. 1114 + } => { 1115 + let mut base_json = 1116 + generate_type_json(base, usage_counts, workspace, current_namespace); 956 1117 957 1118 if let Some(obj) = base_json.as_object_mut() { 958 1119 for constraint in constraints {
+97 -63
mlf-diagnostics/src/lib.rs
··· 49 49 ParseError::InvalidIdentifier { span, .. } => *span, 50 50 }; 51 51 52 - Some(Box::new(std::iter::once( 53 - LabeledSpan::at(span.start..span.end, "here"), 54 - ))) 52 + Some(Box::new(std::iter::once(LabeledSpan::at( 53 + span.start..span.end, 54 + "here", 55 + )))) 55 56 } 56 57 57 58 fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> { ··· 75 76 } 76 77 77 78 impl ValidationDiagnostic { 78 - pub fn new(filename: String, source: String, module_namespace: String, errors: ValidationErrors) -> Self { 79 + pub fn new( 80 + filename: String, 81 + source: String, 82 + module_namespace: String, 83 + errors: ValidationErrors, 84 + ) -> Self { 79 85 Self { 80 86 source_code: NamedSource::new(filename, source), 81 87 module_namespace, ··· 199 205 ValidationError::ReservedName { name, .. } => { 200 206 write!(f, "Reserved name '{}' cannot be used as an item name", name) 201 207 } 202 - ValidationError::AmbiguousMain { name, namespace_suffix, .. } => { 208 + ValidationError::AmbiguousMain { 209 + name, 210 + namespace_suffix, 211 + .. 212 + } => { 203 213 write!( 204 214 f, 205 215 "Ambiguous main definition for '{}' in namespace ending with '{}'. Use @main to disambiguate", ··· 207 217 ) 208 218 } 209 219 ValidationError::MultipleMain { name, .. } => { 210 - write!(f, "Multiple items named '{}' marked with @main. Only one can be @main", name) 220 + write!( 221 + f, 222 + "Multiple items named '{}' marked with @main. Only one can be @main", 223 + name 224 + ) 211 225 } 212 - ValidationError::ConflictNotAllowed { name, namespace_suffix, .. } => { 226 + ValidationError::ConflictNotAllowed { 227 + name, 228 + namespace_suffix, 229 + .. 230 + } => { 213 231 write!( 214 232 f, 215 233 "Name conflict for '{}' is not allowed. Conflicts are only allowed when the name matches the namespace suffix ('{}')", ··· 368 386 "Union types must contain at least one type. Add a type to the union or use a different type.", 369 387 )) 370 388 } 371 - ValidationError::ConstraintTooPermissive { message, .. } if message.contains("maxLength") => { 389 + ValidationError::ConstraintTooPermissive { message, .. } 390 + if message.contains("maxLength") => 391 + { 372 392 Some(Box::new( 373 393 "When refining a constrained type, maxLength can only decrease (become more restrictive).", 374 394 )) 375 395 } 376 - ValidationError::ConstraintTooPermissive { message, .. } if message.contains("minLength") => { 396 + ValidationError::ConstraintTooPermissive { message, .. } 397 + if message.contains("minLength") => 398 + { 377 399 Some(Box::new( 378 400 "When refining a constrained type, minLength can only increase (become more restrictive).", 379 401 )) ··· 388 410 "When refining a constrained type, minimum can only increase (become more restrictive).", 389 411 )) 390 412 } 391 - ValidationError::ConstraintTooPermissive { message, .. } if message.contains("maxGraphemes") => { 413 + ValidationError::ConstraintTooPermissive { message, .. } 414 + if message.contains("maxGraphemes") => 415 + { 392 416 Some(Box::new( 393 417 "When refining a constrained type, maxGraphemes can only decrease (become more restrictive).", 394 418 )) 395 419 } 396 - ValidationError::ConstraintTooPermissive { message, .. } if message.contains("minGraphemes") => { 420 + ValidationError::ConstraintTooPermissive { message, .. } 421 + if message.contains("minGraphemes") => 422 + { 397 423 Some(Box::new( 398 424 "When refining a constrained type, minGraphemes can only increase (become more restrictive).", 399 425 )) ··· 408 434 "When refining a constrained type, enum values must be a subset of the base enum.", 409 435 )) 410 436 } 411 - ValidationError::DuplicateDefinition { .. } => { 412 - Some(Box::new( 413 - "Each name can only be defined once in a module. Consider renaming one of the items or using @main annotation if they match the namespace suffix.", 414 - )) 415 - } 416 - ValidationError::ReservedName { name, .. } if name == "main" => { 417 - Some(Box::new( 418 - "The name 'main' is reserved and cannot be used as an item name. Use @main annotation on an item instead.", 419 - )) 420 - } 421 - ValidationError::ReservedName { name, .. } if name == "defs" => { 422 - Some(Box::new( 423 - "The name 'defs' is reserved for future use and cannot be used as an item name.", 424 - )) 425 - } 426 - ValidationError::AmbiguousMain { .. } => { 427 - Some(Box::new( 428 - "When multiple items have the same name as the namespace suffix, use @main to mark which one is the primary definition.", 429 - )) 430 - } 431 - ValidationError::MultipleMain { .. } => { 432 - Some(Box::new( 433 - "Only one item can be marked with @main. Remove the @main annotation from all but one item.", 434 - )) 435 - } 436 - ValidationError::ConflictNotAllowed { namespace_suffix, .. } => { 437 - Some(Box::new(format!( 438 - "Name conflicts are only allowed when the item name matches the namespace suffix ('{}').", 439 - namespace_suffix 440 - ))) 441 - } 442 - ValidationError::CircularImport { .. } => { 443 - Some(Box::new( 444 - "Circular imports are not allowed. Reorganize your modules to break the cycle.", 445 - )) 446 - } 447 - ValidationError::UnusedImport { .. } => { 448 - Some(Box::new( 449 - "This import is never used. Consider removing it to keep the code clean.", 450 - )) 451 - } 437 + ValidationError::DuplicateDefinition { .. } => Some(Box::new( 438 + "Each name can only be defined once in a module. Consider renaming one of the items or using @main annotation if they match the namespace suffix.", 439 + )), 440 + ValidationError::ReservedName { name, .. } if name == "main" => Some(Box::new( 441 + "The name 'main' is reserved and cannot be used as an item name. Use @main annotation on an item instead.", 442 + )), 443 + ValidationError::ReservedName { name, .. } if name == "defs" => Some(Box::new( 444 + "The name 'defs' is reserved for future use and cannot be used as an item name.", 445 + )), 446 + ValidationError::AmbiguousMain { .. } => Some(Box::new( 447 + "When multiple items have the same name as the namespace suffix, use @main to mark which one is the primary definition.", 448 + )), 449 + ValidationError::MultipleMain { .. } => Some(Box::new( 450 + "Only one item can be marked with @main. Remove the @main annotation from all but one item.", 451 + )), 452 + ValidationError::ConflictNotAllowed { 453 + namespace_suffix, .. 454 + } => Some(Box::new(format!( 455 + "Name conflicts are only allowed when the item name matches the namespace suffix ('{}').", 456 + namespace_suffix 457 + ))), 458 + ValidationError::CircularImport { .. } => Some(Box::new( 459 + "Circular imports are not allowed. Reorganize your modules to break the cycle.", 460 + )), 461 + ValidationError::UnusedImport { .. } => Some(Box::new( 462 + "This import is never used. Consider removing it to keep the code clean.", 463 + )), 452 464 _ => None, 453 465 } 454 466 } 455 467 456 468 pub fn get_error_module_namespace_str(error: &ValidationError) -> &str { 457 469 match error { 458 - ValidationError::DuplicateDefinition { module_namespace, .. } => module_namespace, 459 - ValidationError::UndefinedReference { module_namespace, .. } => module_namespace, 460 - ValidationError::InvalidConstraint { module_namespace, .. } => module_namespace, 461 - ValidationError::TypeMismatch { module_namespace, .. } => module_namespace, 462 - ValidationError::ConstraintTooPermissive { module_namespace, .. } => module_namespace, 463 - ValidationError::ReservedName { module_namespace, .. } => module_namespace, 464 - ValidationError::AmbiguousMain { module_namespace, .. } => module_namespace, 465 - ValidationError::MultipleMain { module_namespace, .. } => module_namespace, 466 - ValidationError::ConflictNotAllowed { module_namespace, .. } => module_namespace, 467 - ValidationError::CircularImport { module_namespace, .. } => module_namespace, 468 - ValidationError::UnusedImport { module_namespace, .. } => module_namespace, 470 + ValidationError::DuplicateDefinition { 471 + module_namespace, .. 472 + } => module_namespace, 473 + ValidationError::UndefinedReference { 474 + module_namespace, .. 475 + } => module_namespace, 476 + ValidationError::InvalidConstraint { 477 + module_namespace, .. 478 + } => module_namespace, 479 + ValidationError::TypeMismatch { 480 + module_namespace, .. 481 + } => module_namespace, 482 + ValidationError::ConstraintTooPermissive { 483 + module_namespace, .. 484 + } => module_namespace, 485 + ValidationError::ReservedName { 486 + module_namespace, .. 487 + } => module_namespace, 488 + ValidationError::AmbiguousMain { 489 + module_namespace, .. 490 + } => module_namespace, 491 + ValidationError::MultipleMain { 492 + module_namespace, .. 493 + } => module_namespace, 494 + ValidationError::ConflictNotAllowed { 495 + module_namespace, .. 496 + } => module_namespace, 497 + ValidationError::CircularImport { 498 + module_namespace, .. 499 + } => module_namespace, 500 + ValidationError::UnusedImport { 501 + module_namespace, .. 502 + } => module_namespace, 469 503 } 470 504 } 471 505
+5 -1
mlf-lang/src/ast.rs
··· 281 281 /// Array type 282 282 Array { inner: Box<Type>, span: Span }, 283 283 /// Union type 284 - Union { types: Vec<Type>, closed: bool, span: Span }, 284 + Union { 285 + types: Vec<Type>, 286 + closed: bool, 287 + span: Span, 288 + }, 285 289 /// Object type (inline) 286 290 Object { fields: Vec<Field>, span: Span }, 287 291 /// Parenthesized type (for grouping, e.g., (A | B)[])
+61 -11
mlf-lang/src/error.rs
··· 34 34 35 35 #[derive(Debug, Clone, PartialEq)] 36 36 pub enum ValidationError { 37 - DuplicateDefinition { name: String, first_span: Span, second_span: Span, module_namespace: String }, 38 - UndefinedReference { name: String, span: Span, module_namespace: String }, 39 - InvalidConstraint { message: String, span: Span, module_namespace: String }, 40 - TypeMismatch { expected: String, found: String, span: Span, module_namespace: String }, 41 - ConstraintTooPermissive { message: String, span: Span, module_namespace: String }, 42 - ReservedName { name: String, span: Span, module_namespace: String }, 43 - AmbiguousMain { name: String, namespace_suffix: String, first_span: Span, second_span: Span, module_namespace: String }, 44 - MultipleMain { name: String, first_span: Span, second_span: Span, module_namespace: String }, 45 - ConflictNotAllowed { name: String, namespace_suffix: String, span: Span, module_namespace: String }, 46 - CircularImport { cycle: Vec<String>, span: Span, module_namespace: String }, 47 - UnusedImport { name: String, span: Span, module_namespace: String }, 37 + DuplicateDefinition { 38 + name: String, 39 + first_span: Span, 40 + second_span: Span, 41 + module_namespace: String, 42 + }, 43 + UndefinedReference { 44 + name: String, 45 + span: Span, 46 + module_namespace: String, 47 + }, 48 + InvalidConstraint { 49 + message: String, 50 + span: Span, 51 + module_namespace: String, 52 + }, 53 + TypeMismatch { 54 + expected: String, 55 + found: String, 56 + span: Span, 57 + module_namespace: String, 58 + }, 59 + ConstraintTooPermissive { 60 + message: String, 61 + span: Span, 62 + module_namespace: String, 63 + }, 64 + ReservedName { 65 + name: String, 66 + span: Span, 67 + module_namespace: String, 68 + }, 69 + AmbiguousMain { 70 + name: String, 71 + namespace_suffix: String, 72 + first_span: Span, 73 + second_span: Span, 74 + module_namespace: String, 75 + }, 76 + MultipleMain { 77 + name: String, 78 + first_span: Span, 79 + second_span: Span, 80 + module_namespace: String, 81 + }, 82 + ConflictNotAllowed { 83 + name: String, 84 + namespace_suffix: String, 85 + span: Span, 86 + module_namespace: String, 87 + }, 88 + CircularImport { 89 + cycle: Vec<String>, 90 + span: Span, 91 + module_namespace: String, 92 + }, 93 + UnusedImport { 94 + name: String, 95 + span: Span, 96 + module_namespace: String, 97 + }, 48 98 } 49 99 50 100 #[derive(Debug, Clone, Default)]
+28 -28
mlf-lang/src/lexer.rs
··· 1 1 use alloc::string::String; 2 2 use alloc::vec::Vec; 3 3 use nom::{ 4 + IResult, Parser, 4 5 branch::alt, 5 6 bytes::complete::{tag, take_while, take_while1}, 6 7 character::complete::{char, multispace0, one_of}, 7 8 combinator::{map, opt, recognize}, 8 9 multi::many0, 9 10 sequence::{delimited, pair, preceded}, 10 - IResult, Parser, 11 11 }; 12 12 13 13 use crate::span::Span; ··· 142 142 let (rest, name) = recognize(pair( 143 143 take_while1(is_ident_start), 144 144 take_while(is_ident_continue), 145 - )).parse(input)?; 145 + )) 146 + .parse(input)?; 146 147 147 148 let token = match name { 148 149 "as" => Token::As, ··· 178 179 let (rest, name) = recognize(pair( 179 180 take_while1(is_type_start), 180 181 take_while(is_ident_continue), 181 - )).parse(input)?; 182 + )) 183 + .parse(input)?; 182 184 183 185 Ok((rest, Token::Ident(name.into()))) 184 186 } 185 187 186 188 fn raw_identifier(input: &str) -> IResult<&str, Token> { 187 - let (rest, name) = delimited( 188 - char('`'), 189 - take_while1(|c: char| c != '`'), 190 - char('`'), 191 - ).parse(input)?; 189 + let (rest, name) = 190 + delimited(char('`'), take_while1(|c: char| c != '`'), char('`')).parse(input)?; 192 191 193 192 Ok((rest, Token::Ident(name.into()))) 194 193 } ··· 201 200 recognize(pair(char('\\'), one_of(r#""\/bfnrt"#))), 202 201 )))), 203 202 char('"'), 204 - ).parse(input)?; 203 + ) 204 + .parse(input)?; 205 205 206 206 Ok((rest, Token::StringLit(s.into()))) 207 207 } ··· 210 210 let (rest, num_str) = recognize(pair( 211 211 opt(char('-')), 212 212 take_while1(|c: char| c.is_ascii_digit()), 213 - )).parse(input)?; 213 + )) 214 + .parse(input)?; 214 215 215 216 let num = num_str.parse::<i64>().unwrap(); 216 217 Ok((rest, Token::IntLit(num))) ··· 222 223 take_while1(|c: char| c.is_ascii_digit()), 223 224 char('.'), 224 225 take_while1(|c: char| c.is_ascii_digit()), 225 - )).parse(input)?; 226 + )) 227 + .parse(input)?; 226 228 227 229 let num = num_str.parse::<f64>().unwrap(); 228 230 Ok((rest, Token::FloatLit(num))) ··· 231 233 fn doc_comment(input: &str) -> IResult<&str, Token> { 232 234 let (rest, comment) = preceded( 233 235 tag("///"), 234 - map( 235 - take_while(|c| c != '\n'), 236 - |s: &str| Token::DocComment(s.trim().into()), 237 - ), 238 - ).parse(input)?; 236 + map(take_while(|c| c != '\n'), |s: &str| { 237 + Token::DocComment(s.trim().into()) 238 + }), 239 + ) 240 + .parse(input)?; 239 241 240 242 Ok((rest, comment)) 241 243 } 242 244 243 245 fn line_comment(input: &str) -> IResult<&str, ()> { 244 - let (rest, _) = preceded( 245 - tag("//"), 246 - take_while(|c| c != '\n'), 247 - ).parse(input)?; 246 + let (rest, _) = preceded(tag("//"), take_while(|c| c != '\n')).parse(input)?; 248 247 249 248 Ok((rest, ())) 250 249 } 251 250 252 251 fn hash_comment(input: &str) -> IResult<&str, ()> { 253 - let (rest, _) = preceded( 254 - char('#'), 255 - take_while(|c| c != '\n'), 256 - ).parse(input)?; 252 + let (rest, _) = preceded(char('#'), take_while(|c| c != '\n')).parse(input)?; 257 253 258 254 Ok((rest, ())) 259 255 } ··· 276 272 map(char('('), |_| Token::LeftParen), 277 273 map(char(')'), |_| Token::RightParen), 278 274 map(char('_'), |_| Token::Underscore), 279 - )).parse(input) 275 + )) 276 + .parse(input) 280 277 } 281 278 282 279 fn single_token(input: &str) -> IResult<&str, Option<Token>> { ··· 291 288 map(type_ident, Some), 292 289 map(symbol, Some), // Parse symbols before identifiers so _ is caught 293 290 map(identifier, Some), 294 - )).parse(input) 291 + )) 292 + .parse(input) 295 293 } 296 294 297 295 pub fn tokenize(input: &str) -> Result<Vec<SpannedToken>, crate::error::ParseError> { ··· 300 298 let mut pos = 0; 301 299 302 300 while !remaining.is_empty() { 303 - 304 301 let ws_result: IResult<&str, &str> = multispace0(remaining); 305 302 if let Ok((rest, ws)) = ws_result { 306 303 pos += ws.len(); ··· 381 378 fn test_doc_comment() { 382 379 let input = "/// This is a doc comment"; 383 380 let tokens = tokenize(input).unwrap(); 384 - assert_eq!(tokens[0].token, Token::DocComment("This is a doc comment".into())); 381 + assert_eq!( 382 + tokens[0].token, 383 + Token::DocComment("This is a doc comment".into()) 384 + ); 385 385 } 386 386 387 387 #[test]
+1 -1
mlf-lang/src/lib.rs
··· 17 17 pub use workspace::{ResolvedRef, Workspace}; 18 18 19 19 // Standard library directory 20 - use include_dir::{include_dir, Dir}; 20 + use include_dir::{Dir, include_dir}; 21 21 22 22 pub static STD_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../std"); 23 23
+109 -57
mlf-lang/src/parser.rs
··· 1 + use crate::lexer::Token as LexToken; 2 + use crate::{ 3 + Lexicon, ParseError, 4 + ast::*, 5 + lexer::{SpannedToken, tokenize}, 6 + span::{Span, Spanned}, 7 + }; 1 8 use alloc::vec::Vec; 2 - use crate::{Lexicon, ParseError, ast::*, lexer::{tokenize, SpannedToken}, span::{Span, Spanned}}; 3 - use crate::lexer::Token as LexToken; 4 9 5 10 struct Parser { 6 11 tokens: Vec<SpannedToken>, ··· 264 269 let first_ident = self.parse_ident()?; 265 270 266 271 // Check if this is a generator selector or bare annotation 267 - let (selectors, name) = if matches!(self.current().token, LexToken::Comma) || matches!(self.current().token, LexToken::Colon) { 272 + let (selectors, name) = if matches!(self.current().token, LexToken::Comma) 273 + || matches!(self.current().token, LexToken::Colon) 274 + { 268 275 // Generator selector syntax: @rust:deprecated or @rust,typescript:deprecated 269 276 let mut selectors = alloc::vec![first_ident]; 270 277 ··· 427 434 Ok(AnnotationValue::Object(entries)) 428 435 } 429 436 430 - fn parse_record(&mut self, docs: Vec<DocComment>, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 437 + fn parse_record( 438 + &mut self, 439 + docs: Vec<DocComment>, 440 + annotations: Vec<Annotation>, 441 + ) -> Result<Item, ParseError> { 431 442 let start = self.expect(LexToken::Record)?; 432 443 let name = self.parse_ident()?; 433 444 self.expect(LexToken::LeftBrace)?; ··· 467 478 // Check for ! to mark as required (default is optional) 468 479 let optional = if matches!(self.current().token, LexToken::Exclamation) { 469 480 self.advance(); 470 - false // ! means required 481 + false // ! means required 471 482 } else { 472 - true // default is optional 483 + true // default is optional 473 484 }; 474 485 475 486 self.expect(LexToken::Colon)?; ··· 495 506 }) 496 507 } 497 508 498 - fn parse_inline_type(&mut self, docs: Vec<DocComment>, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 509 + fn parse_inline_type( 510 + &mut self, 511 + docs: Vec<DocComment>, 512 + annotations: Vec<Annotation>, 513 + ) -> Result<Item, ParseError> { 499 514 let start = self.expect(LexToken::Inline)?; 500 515 self.expect(LexToken::Type)?; 501 516 let name = self.parse_ident()?; ··· 512 527 })) 513 528 } 514 529 515 - fn parse_def_type(&mut self, docs: Vec<DocComment>, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 530 + fn parse_def_type( 531 + &mut self, 532 + docs: Vec<DocComment>, 533 + annotations: Vec<Annotation>, 534 + ) -> Result<Item, ParseError> { 516 535 let start = self.expect(LexToken::Def)?; 517 536 self.expect(LexToken::Type)?; 518 - let name = self.parse_ident()?; // Backticked keywords are already converted to Ident by lexer 537 + let name = self.parse_ident()?; // Backticked keywords are already converted to Ident by lexer 519 538 self.expect(LexToken::Equals)?; 520 539 let ty = self.parse_type()?; 521 540 let end = self.expect(LexToken::Semicolon)?; ··· 529 548 })) 530 549 } 531 550 532 - fn parse_token(&mut self, docs: Vec<DocComment>, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 551 + fn parse_token( 552 + &mut self, 553 + docs: Vec<DocComment>, 554 + annotations: Vec<Annotation>, 555 + ) -> Result<Item, ParseError> { 533 556 let start = self.expect(LexToken::Token)?; 534 557 let name = self.parse_ident()?; 535 558 let end = self.expect(LexToken::Semicolon)?; ··· 542 565 })) 543 566 } 544 567 545 - fn parse_query(&mut self, docs: Vec<DocComment>, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 568 + fn parse_query( 569 + &mut self, 570 + docs: Vec<DocComment>, 571 + annotations: Vec<Annotation>, 572 + ) -> Result<Item, ParseError> { 546 573 let start = self.expect(LexToken::Query)?; 547 574 let name = self.parse_ident()?; 548 575 self.expect(LexToken::LeftParen)?; ··· 579 606 580 607 let span = Span::new(types[0].span().start, types.last().unwrap().span().end); 581 608 // Return type unions are open by default (no ! support in return types yet) 582 - ReturnType::Type(Type::Union { types, closed: false, span }) 609 + ReturnType::Type(Type::Union { 610 + types, 611 + closed: false, 612 + span, 613 + }) 583 614 } 584 615 } else { 585 616 ReturnType::Type(output) 586 617 } 587 618 } else { 588 619 // No return type specified 589 - ReturnType::None { span: right_paren_span } 620 + ReturnType::None { 621 + span: right_paren_span, 622 + } 590 623 }; 591 624 592 625 let end = self.expect(LexToken::Semicolon)?; ··· 601 634 })) 602 635 } 603 636 604 - fn parse_procedure(&mut self, docs: Vec<DocComment>, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 637 + fn parse_procedure( 638 + &mut self, 639 + docs: Vec<DocComment>, 640 + annotations: Vec<Annotation>, 641 + ) -> Result<Item, ParseError> { 605 642 let start = self.expect(LexToken::Procedure)?; 606 643 let name = self.parse_ident()?; 607 644 self.expect(LexToken::LeftParen)?; ··· 638 675 639 676 let span = Span::new(types[0].span().start, types.last().unwrap().span().end); 640 677 // Return type unions are open by default (no ! support in return types yet) 641 - ReturnType::Type(Type::Union { types, closed: false, span }) 678 + ReturnType::Type(Type::Union { 679 + types, 680 + closed: false, 681 + span, 682 + }) 642 683 } 643 684 } else { 644 685 ReturnType::Type(output) 645 686 } 646 687 } else { 647 688 // No return type specified 648 - ReturnType::None { span: right_paren_span } 689 + ReturnType::None { 690 + span: right_paren_span, 691 + } 649 692 }; 650 693 651 694 let end = self.expect(LexToken::Semicolon)?; ··· 660 703 })) 661 704 } 662 705 663 - fn parse_subscription(&mut self, docs: Vec<DocComment>, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 706 + fn parse_subscription( 707 + &mut self, 708 + docs: Vec<DocComment>, 709 + annotations: Vec<Annotation>, 710 + ) -> Result<Item, ParseError> { 664 711 let start = self.expect(LexToken::Subscription)?; 665 712 let name = self.parse_ident()?; 666 713 self.expect(LexToken::LeftParen)?; ··· 736 783 self.advance(); 737 784 } else if !matches!(self.current().token, LexToken::RightBrace) { 738 785 return Err(ParseError::Syntax { 739 - message: alloc::format!("Expected comma or closing brace, found {}", self.current().token), 786 + message: alloc::format!( 787 + "Expected comma or closing brace, found {}", 788 + self.current().token 789 + ), 740 790 span: self.current().span, 741 791 }); 742 792 } ··· 785 835 // Check for ! to mark as required (default is optional) 786 836 let optional = if matches!(self.current().token, LexToken::Exclamation) { 787 837 self.advance(); 788 - false // ! means required 838 + false // ! means required 789 839 } else { 790 - true // default is optional 840 + true // default is optional 791 841 }; 792 842 793 843 self.expect(LexToken::Colon)?; ··· 880 930 let span = Span::new(types[0].span().start, types.last().unwrap().span().end); 881 931 // Unions are open by default, closed if ! is present 882 932 let closed = has_exclamation; 883 - return Ok(Type::Union { types, closed, span }); 933 + return Ok(Type::Union { 934 + types, 935 + closed, 936 + span, 937 + }); 884 938 } 885 939 886 940 Ok(base) ··· 1113 1167 } 1114 1168 _ => { 1115 1169 return Err(ParseError::Syntax { 1116 - message: alloc::format!("Expected string literal or identifier in enum"), 1170 + message: alloc::format!( 1171 + "Expected string literal or identifier in enum" 1172 + ), 1117 1173 span: current.span, 1118 1174 }); 1119 1175 } ··· 1258 1314 } 1259 1315 _ => { 1260 1316 return Err(ParseError::Syntax { 1261 - message: alloc::format!("Expected string literal or identifier in knownValues"), 1317 + message: alloc::format!( 1318 + "Expected string literal or identifier in knownValues" 1319 + ), 1262 1320 span: current.span, 1263 1321 }); 1264 1322 } ··· 1314 1372 } 1315 1373 _ => { 1316 1374 return Err(ParseError::Syntax { 1317 - message: alloc::format!("Expected string, integer, boolean, or identifier for default"), 1375 + message: alloc::format!( 1376 + "Expected string, integer, boolean, or identifier for default" 1377 + ), 1318 1378 span: current_span, 1319 1379 }); 1320 1380 } ··· 1354 1414 } 1355 1415 _ => { 1356 1416 return Err(ParseError::Syntax { 1357 - message: alloc::format!("Expected string, integer, boolean, or identifier for const"), 1417 + message: alloc::format!( 1418 + "Expected string, integer, boolean, or identifier for const" 1419 + ), 1358 1420 span: current_span, 1359 1421 }); 1360 1422 } ··· 1500 1562 let lexicon = result.unwrap(); 1501 1563 assert_eq!(lexicon.items.len(), 1); 1502 1564 match &lexicon.items[0] { 1503 - Item::InlineType(a) => { 1504 - match &a.ty { 1505 - Type::Constrained { constraints, .. } => { 1506 - assert_eq!(constraints.len(), 1); 1507 - } 1508 - _ => panic!("Expected constrained type"), 1565 + Item::InlineType(a) => match &a.ty { 1566 + Type::Constrained { constraints, .. } => { 1567 + assert_eq!(constraints.len(), 1); 1509 1568 } 1510 - } 1569 + _ => panic!("Expected constrained type"), 1570 + }, 1511 1571 _ => panic!("Expected inline type"), 1512 1572 } 1513 1573 } ··· 1520 1580 let lexicon = result.unwrap(); 1521 1581 assert_eq!(lexicon.items.len(), 1); 1522 1582 match &lexicon.items[0] { 1523 - Item::InlineType(a) => { 1524 - match &a.ty { 1525 - Type::Union { types, .. } => { 1526 - assert_eq!(types.len(), 2); 1527 - } 1528 - _ => panic!("Expected union type"), 1583 + Item::InlineType(a) => match &a.ty { 1584 + Type::Union { types, .. } => { 1585 + assert_eq!(types.len(), 2); 1529 1586 } 1530 - } 1587 + _ => panic!("Expected union type"), 1588 + }, 1531 1589 _ => panic!("Expected inline type"), 1532 1590 } 1533 1591 } ··· 1540 1598 let lexicon = result.unwrap(); 1541 1599 assert_eq!(lexicon.items.len(), 1); 1542 1600 match &lexicon.items[0] { 1543 - Item::InlineType(a) => { 1544 - match &a.ty { 1545 - Type::Array { .. } => {} 1546 - _ => panic!("Expected array type"), 1547 - } 1548 - } 1601 + Item::InlineType(a) => match &a.ty { 1602 + Type::Array { .. } => {} 1603 + _ => panic!("Expected array type"), 1604 + }, 1549 1605 _ => panic!("Expected inline type"), 1550 1606 } 1551 1607 } ··· 1756 1812 assert!(result.is_ok()); 1757 1813 let lexicon = result.unwrap(); 1758 1814 match &lexicon.items[0] { 1759 - Item::InlineType(a) => { 1760 - match &a.ty { 1761 - Type::Constrained { constraints, .. } => { 1762 - match &constraints[0] { 1763 - Constraint::Enum { values, .. } => { 1764 - assert_eq!(values.len(), 2); 1765 - } 1766 - _ => panic!("Expected enum constraint"), 1767 - } 1815 + Item::InlineType(a) => match &a.ty { 1816 + Type::Constrained { constraints, .. } => match &constraints[0] { 1817 + Constraint::Enum { values, .. } => { 1818 + assert_eq!(values.len(), 2); 1768 1819 } 1769 - _ => panic!("Expected constrained type"), 1770 - } 1771 - } 1820 + _ => panic!("Expected enum constraint"), 1821 + }, 1822 + _ => panic!("Expected constrained type"), 1823 + }, 1772 1824 _ => panic!("Expected inline type"), 1773 1825 } 1774 1826 }
+370 -143
mlf-lang/src/workspace.rs
··· 1 + use crate::{ 2 + ast::*, 3 + error::{ValidationError, ValidationErrors}, 4 + span::Span, 5 + }; 1 6 use alloc::collections::{BTreeMap, BTreeSet}; 2 7 use alloc::string::String; 3 8 use alloc::vec::Vec; 4 - use crate::{ast::*, error::{ValidationError, ValidationErrors}, span::Span}; 5 9 6 10 #[derive(Debug, Clone, PartialEq)] 7 11 pub struct Workspace { ··· 77 81 78 82 pub fn with_prelude() -> Result<Self, ValidationErrors> { 79 83 let mut ws = Self::new(); 80 - let prelude_lexicon = crate::parser::parse_lexicon(crate::PRELUDE) 81 - .map_err(|e| { 82 - let mut errors = ValidationErrors::new(); 83 - errors.push(ValidationError::InvalidConstraint { 84 - message: alloc::format!("Failed to parse prelude: {:?}", e), 85 - span: crate::span::Span::new(0, 0), 86 - module_namespace: "prelude".to_string(), 87 - }); 88 - errors 89 - })?; 84 + let prelude_lexicon = crate::parser::parse_lexicon(crate::PRELUDE).map_err(|e| { 85 + let mut errors = ValidationErrors::new(); 86 + errors.push(ValidationError::InvalidConstraint { 87 + message: alloc::format!("Failed to parse prelude: {:?}", e), 88 + span: crate::span::Span::new(0, 0), 89 + module_namespace: "prelude".to_string(), 90 + }); 91 + errors 92 + })?; 90 93 ws.add_module("prelude".into(), prelude_lexicon)?; 91 94 Ok(ws) 92 95 } ··· 95 98 let mut ws = Self::new(); 96 99 97 100 // Add prelude 98 - let prelude_lexicon = crate::parser::parse_lexicon(crate::PRELUDE) 99 - .map_err(|e| { 100 - let mut errors = ValidationErrors::new(); 101 - errors.push(ValidationError::InvalidConstraint { 102 - message: alloc::format!("Failed to parse prelude: {:?}", e), 103 - span: crate::span::Span::new(0, 0), 104 - module_namespace: "prelude".to_string(), 105 - }); 106 - errors 107 - })?; 101 + let prelude_lexicon = crate::parser::parse_lexicon(crate::PRELUDE).map_err(|e| { 102 + let mut errors = ValidationErrors::new(); 103 + errors.push(ValidationError::InvalidConstraint { 104 + message: alloc::format!("Failed to parse prelude: {:?}", e), 105 + span: crate::span::Span::new(0, 0), 106 + module_namespace: "prelude".to_string(), 107 + }); 108 + errors 109 + })?; 108 110 ws.add_module("prelude".into(), prelude_lexicon)?; 109 111 110 112 // Recursively load all .mlf files from the std directory ··· 124 126 .replace('/', "."); 125 127 126 128 if let Some(contents) = file.contents_utf8() { 127 - let lexicon = crate::parser::parse_lexicon(contents) 128 - .map_err(|e| { 129 - let mut errors = ValidationErrors::new(); 130 - errors.push(ValidationError::InvalidConstraint { 131 - message: alloc::format!("Failed to parse {} (file: {}): {:?}", namespace, path_str, e), 132 - span: crate::span::Span::new(0, 0), 133 - module_namespace: namespace.clone(), 134 - }); 135 - errors 136 - })?; 129 + let lexicon = crate::parser::parse_lexicon(contents).map_err(|e| { 130 + let mut errors = ValidationErrors::new(); 131 + errors.push(ValidationError::InvalidConstraint { 132 + message: alloc::format!( 133 + "Failed to parse {} (file: {}): {:?}", 134 + namespace, 135 + path_str, 136 + e 137 + ), 138 + span: crate::span::Span::new(0, 0), 139 + module_namespace: namespace.clone(), 140 + }); 141 + errors 142 + })?; 137 143 138 144 // If the module already exists, merge the items 139 145 if ws.modules.contains_key(&namespace) { 140 146 let module = ws.modules.get_mut(&namespace).unwrap(); 141 147 module.lexicon.items.extend(lexicon.items); 142 148 // Rebuild symbol table 143 - module.symbols = Workspace::build_symbol_table(&namespace, &module.lexicon)?; 149 + module.symbols = 150 + Workspace::build_symbol_table(&namespace, &module.lexicon)?; 144 151 } else { 145 152 ws.add_module(namespace, lexicon)?; 146 153 } ··· 161 168 Ok(ws) 162 169 } 163 170 164 - pub fn add_module(&mut self, namespace: String, lexicon: Lexicon) -> Result<(), ValidationErrors> { 171 + pub fn add_module( 172 + &mut self, 173 + namespace: String, 174 + lexicon: Lexicon, 175 + ) -> Result<(), ValidationErrors> { 165 176 let symbols = Self::build_symbol_table(&namespace, &lexicon)?; 166 177 167 178 let module = Module { ··· 205 216 } 206 217 207 218 // Filter out warnings (UnusedImport) from blocking errors 208 - let blocking_errors: Vec<ValidationError> = errors.errors.into_iter() 219 + let blocking_errors: Vec<ValidationError> = errors 220 + .errors 221 + .into_iter() 209 222 .filter(|e| !matches!(e, ValidationError::UnusedImport { .. })) 210 223 .collect(); 211 224 212 225 if blocking_errors.is_empty() { 213 226 Ok(()) 214 227 } else { 215 - Err(ValidationErrors { errors: blocking_errors }) 228 + Err(ValidationErrors { 229 + errors: blocking_errors, 230 + }) 216 231 } 217 232 } 218 233 ··· 284 299 } 285 300 } 286 301 287 - fn typecheck_inline_type(&self, namespace: &str, inline_type: &InlineType) -> Result<(), ValidationErrors> { 302 + fn typecheck_inline_type( 303 + &self, 304 + namespace: &str, 305 + inline_type: &InlineType, 306 + ) -> Result<(), ValidationErrors> { 288 307 self.typecheck_type(namespace, &inline_type.ty) 289 308 } 290 309 291 - fn typecheck_def_type(&self, namespace: &str, def_type: &DefType) -> Result<(), ValidationErrors> { 310 + fn typecheck_def_type( 311 + &self, 312 + namespace: &str, 313 + def_type: &DefType, 314 + ) -> Result<(), ValidationErrors> { 292 315 self.typecheck_type(namespace, &def_type.ty) 293 316 } 294 317 ··· 352 375 } 353 376 } 354 377 Type::Parenthesized { inner, .. } => self.typecheck_type(namespace, inner), 355 - Type::Constrained { base, constraints, span } => { 378 + Type::Constrained { 379 + base, 380 + constraints, 381 + span, 382 + } => { 356 383 let mut errors = ValidationErrors::new(); 357 384 358 385 if let Err(mut base_errors) = self.typecheck_type(namespace, base) { 359 386 errors.append(&mut base_errors); 360 387 } 361 388 362 - if let Err(mut constraint_errors) = self.typecheck_constraints(namespace, base, constraints, *span) { 389 + if let Err(mut constraint_errors) = 390 + self.typecheck_constraints(namespace, base, constraints, *span) 391 + { 363 392 errors.append(&mut constraint_errors); 364 393 } 365 394 366 - if let Err(mut refinement_errors) = self.check_constraint_refinement(namespace, base, constraints) { 395 + if let Err(mut refinement_errors) = 396 + self.check_constraint_refinement(namespace, base, constraints) 397 + { 367 398 errors.append(&mut refinement_errors); 368 399 } 369 400 ··· 376 407 } 377 408 } 378 409 379 - fn typecheck_constraints(&self, namespace: &str, base: &Type, constraints: &[Constraint], _span: Span) -> Result<(), ValidationErrors> { 410 + fn typecheck_constraints( 411 + &self, 412 + namespace: &str, 413 + base: &Type, 414 + constraints: &[Constraint], 415 + _span: Span, 416 + ) -> Result<(), ValidationErrors> { 380 417 let mut errors = ValidationErrors::new(); 381 418 382 419 let base_kind = self.get_base_primitive(base); 383 420 384 421 for constraint in constraints { 385 422 match constraint { 386 - Constraint::MinLength { span, .. } 387 - | Constraint::MaxLength { span, .. } => { 423 + Constraint::MinLength { span, .. } | Constraint::MaxLength { span, .. } => { 388 424 // MinLength/MaxLength can be applied to strings or arrays 389 425 let is_string = matches!(base_kind, Some(PrimitiveType::String)); 390 426 let is_array = matches!(base, Type::Array { .. }); 391 427 if !is_string && !is_array { 392 428 errors.push(ValidationError::InvalidConstraint { 393 - message: alloc::format!("Length constraint can only be applied to string or array types"), 429 + message: alloc::format!( 430 + "Length constraint can only be applied to string or array types" 431 + ), 394 432 span: *span, 395 433 module_namespace: namespace.to_string(), 396 434 }); ··· 408 446 }); 409 447 } 410 448 } 411 - Constraint::Minimum { span, .. } 412 - | Constraint::Maximum { span, .. } => { 449 + Constraint::Minimum { span, .. } | Constraint::Maximum { span, .. } => { 413 450 if !matches!(base_kind, Some(PrimitiveType::Integer)) { 414 451 errors.push(ValidationError::InvalidConstraint { 415 452 message: alloc::format!("Numeric constraint on non-numeric type"), ··· 418 455 }); 419 456 } 420 457 } 421 - Constraint::Accept { span, .. } 422 - | Constraint::MaxSize { span, .. } => { 458 + Constraint::Accept { span, .. } | Constraint::MaxSize { span, .. } => { 423 459 if !matches!(base_kind, Some(PrimitiveType::Blob)) { 424 460 errors.push(ValidationError::InvalidConstraint { 425 461 message: alloc::format!("Blob constraint on non-blob type"), ··· 441 477 } 442 478 } 443 479 444 - fn check_constraint_refinement(&self, namespace: &str, base: &Type, new_constraints: &[Constraint]) -> Result<(), ValidationErrors> { 480 + fn check_constraint_refinement( 481 + &self, 482 + namespace: &str, 483 + base: &Type, 484 + new_constraints: &[Constraint], 485 + ) -> Result<(), ValidationErrors> { 445 486 let base_constraints = self.get_base_constraints(base); 446 487 447 488 if base_constraints.is_empty() { ··· 452 493 453 494 for new_constraint in new_constraints { 454 495 match new_constraint { 455 - Constraint::MaxLength { value: new_max, span } => { 496 + Constraint::MaxLength { 497 + value: new_max, 498 + span, 499 + } => { 456 500 for base_constraint in &base_constraints { 457 - if let Constraint::MaxLength { value: base_max, .. } = base_constraint { 501 + if let Constraint::MaxLength { 502 + value: base_max, .. 503 + } = base_constraint 504 + { 458 505 if new_max > base_max { 459 506 errors.push(ValidationError::ConstraintTooPermissive { 460 507 message: alloc::format!( 461 508 "maxLength {} is greater than base maxLength {}", 462 - new_max, base_max 509 + new_max, 510 + base_max 463 511 ), 464 512 span: *span, 465 513 module_namespace: namespace.to_string(), ··· 468 516 } 469 517 } 470 518 } 471 - Constraint::MinLength { value: new_min, span } => { 519 + Constraint::MinLength { 520 + value: new_min, 521 + span, 522 + } => { 472 523 for base_constraint in &base_constraints { 473 - if let Constraint::MinLength { value: base_min, .. } = base_constraint { 524 + if let Constraint::MinLength { 525 + value: base_min, .. 526 + } = base_constraint 527 + { 474 528 if new_min < base_min { 475 529 errors.push(ValidationError::ConstraintTooPermissive { 476 530 message: alloc::format!( 477 531 "minLength {} is less than base minLength {}", 478 - new_min, base_min 532 + new_min, 533 + base_min 479 534 ), 480 535 span: *span, 481 536 module_namespace: namespace.to_string(), ··· 484 539 } 485 540 } 486 541 } 487 - Constraint::Maximum { value: new_max, span } => { 542 + Constraint::Maximum { 543 + value: new_max, 544 + span, 545 + } => { 488 546 for base_constraint in &base_constraints { 489 - if let Constraint::Maximum { value: base_max, .. } = base_constraint { 547 + if let Constraint::Maximum { 548 + value: base_max, .. 549 + } = base_constraint 550 + { 490 551 if new_max > base_max { 491 552 errors.push(ValidationError::ConstraintTooPermissive { 492 553 message: alloc::format!( 493 554 "maximum {} is greater than base maximum {}", 494 - new_max, base_max 555 + new_max, 556 + base_max 495 557 ), 496 558 span: *span, 497 559 module_namespace: namespace.to_string(), ··· 500 562 } 501 563 } 502 564 } 503 - Constraint::Minimum { value: new_min, span } => { 565 + Constraint::Minimum { 566 + value: new_min, 567 + span, 568 + } => { 504 569 for base_constraint in &base_constraints { 505 - if let Constraint::Minimum { value: base_min, .. } = base_constraint { 570 + if let Constraint::Minimum { 571 + value: base_min, .. 572 + } = base_constraint 573 + { 506 574 if new_min < base_min { 507 575 errors.push(ValidationError::ConstraintTooPermissive { 508 576 message: alloc::format!( 509 577 "minimum {} is less than base minimum {}", 510 - new_min, base_min 578 + new_min, 579 + base_min 511 580 ), 512 581 span: *span, 513 582 module_namespace: namespace.to_string(), ··· 516 585 } 517 586 } 518 587 } 519 - Constraint::MaxGraphemes { value: new_max, span } => { 588 + Constraint::MaxGraphemes { 589 + value: new_max, 590 + span, 591 + } => { 520 592 for base_constraint in &base_constraints { 521 - if let Constraint::MaxGraphemes { value: base_max, .. } = base_constraint { 593 + if let Constraint::MaxGraphemes { 594 + value: base_max, .. 595 + } = base_constraint 596 + { 522 597 if new_max > base_max { 523 598 errors.push(ValidationError::ConstraintTooPermissive { 524 599 message: alloc::format!( 525 600 "maxGraphemes {} is greater than base maxGraphemes {}", 526 - new_max, base_max 601 + new_max, 602 + base_max 527 603 ), 528 604 span: *span, 529 605 module_namespace: namespace.to_string(), ··· 532 608 } 533 609 } 534 610 } 535 - Constraint::MinGraphemes { value: new_min, span } => { 611 + Constraint::MinGraphemes { 612 + value: new_min, 613 + span, 614 + } => { 536 615 for base_constraint in &base_constraints { 537 - if let Constraint::MinGraphemes { value: base_min, .. } = base_constraint { 616 + if let Constraint::MinGraphemes { 617 + value: base_min, .. 618 + } = base_constraint 619 + { 538 620 if new_min < base_min { 539 621 errors.push(ValidationError::ConstraintTooPermissive { 540 622 message: alloc::format!( 541 623 "minGraphemes {} is less than base minGraphemes {}", 542 - new_min, base_min 624 + new_min, 625 + base_min 543 626 ), 544 627 span: *span, 545 628 module_namespace: namespace.to_string(), ··· 548 631 } 549 632 } 550 633 } 551 - Constraint::MaxSize { value: new_max, span } => { 634 + Constraint::MaxSize { 635 + value: new_max, 636 + span, 637 + } => { 552 638 for base_constraint in &base_constraints { 553 - if let Constraint::MaxSize { value: base_max, .. } = base_constraint { 639 + if let Constraint::MaxSize { 640 + value: base_max, .. 641 + } = base_constraint 642 + { 554 643 if new_max > base_max { 555 644 errors.push(ValidationError::ConstraintTooPermissive { 556 645 message: alloc::format!( 557 646 "maxSize {} is greater than base maxSize {}", 558 - new_max, base_max 647 + new_max, 648 + base_max 559 649 ), 560 650 span: *span, 561 651 module_namespace: namespace.to_string(), ··· 564 654 } 565 655 } 566 656 } 567 - Constraint::Enum { values: new_values, span } => { 657 + Constraint::Enum { 658 + values: new_values, 659 + span, 660 + } => { 568 661 for base_constraint in &base_constraints { 569 - if let Constraint::Enum { values: base_values, .. } = base_constraint { 662 + if let Constraint::Enum { 663 + values: base_values, 664 + .. 665 + } = base_constraint 666 + { 570 667 for new_val in new_values { 571 668 if !base_values.contains(new_val) { 572 669 errors.push(ValidationError::ConstraintTooPermissive { ··· 595 692 596 693 fn get_base_constraints(&self, ty: &Type) -> Vec<Constraint> { 597 694 match ty { 598 - Type::Constrained { base, constraints, .. } => { 695 + Type::Constrained { 696 + base, constraints, .. 697 + } => { 599 698 let mut all_constraints = constraints.clone(); 600 699 all_constraints.extend(self.get_base_constraints(base)); 601 700 all_constraints ··· 734 833 /// Returns a vector of (local_name, original_path) tuples 735 834 pub fn get_imports(&self, namespace: &str) -> Vec<(String, Vec<String>)> { 736 835 if let Some(module) = self.modules.get(namespace) { 737 - module.imports.mappings.iter() 738 - .map(|(local_name, imported)| { 739 - (local_name.clone(), imported.original_path.clone()) 740 - }) 836 + module 837 + .imports 838 + .mappings 839 + .iter() 840 + .map(|(local_name, imported)| (local_name.clone(), imported.original_path.clone())) 741 841 .collect() 742 842 } else { 743 843 Vec::new() ··· 802 902 803 903 if let Some(module) = self.modules.get(current_namespace) { 804 904 if module.symbols.types.contains_key(name) { 805 - return Some(ResolvedRef::Local { def_name: name.clone() }); 905 + return Some(ResolvedRef::Local { 906 + def_name: name.clone(), 907 + }); 806 908 } 807 909 808 910 if let Some(imported) = module.imports.mappings.get(name) { ··· 810 912 // (e.g. `["com", "atproto", "label", "defs", "label"]`). 811 913 // All but the last segment form the namespace. 812 914 if imported.original_path.len() > 1 { 813 - let namespace = imported.original_path[..imported.original_path.len() - 1].join("."); 915 + let namespace = 916 + imported.original_path[..imported.original_path.len() - 1].join("."); 814 917 let def_name = imported.original_path.last().unwrap().clone(); 815 - return Some(ResolvedRef::External { namespace, def_name }); 918 + return Some(ResolvedRef::External { 919 + namespace, 920 + def_name, 921 + }); 816 922 } else { 817 923 // Pathological single-segment import: treat as local. 818 - return Some(ResolvedRef::Local { def_name: name.clone() }); 924 + return Some(ResolvedRef::Local { 925 + def_name: name.clone(), 926 + }); 819 927 } 820 928 } 821 929 } ··· 844 952 if module.symbols.types.contains_key(type_name) { 845 953 let namespace = target_namespace; 846 954 if namespace == current_namespace { 847 - return Some(ResolvedRef::Local { def_name: type_name.clone() }); 955 + return Some(ResolvedRef::Local { 956 + def_name: type_name.clone(), 957 + }); 848 958 } 849 - return Some(ResolvedRef::External { namespace, def_name: type_name.clone() }); 959 + return Some(ResolvedRef::External { 960 + namespace, 961 + def_name: type_name.clone(), 962 + }); 850 963 } 851 964 } 852 965 ··· 871 984 /// Thin shim over [`Self::resolve_ref`]; prefer that for new call 872 985 /// sites where the variant (local vs external vs implicit-main) 873 986 /// matters. 874 - pub fn resolve_reference_namespace(&self, path: &Path, current_namespace: &str) -> Option<String> { 987 + pub fn resolve_reference_namespace( 988 + &self, 989 + path: &Path, 990 + current_namespace: &str, 991 + ) -> Option<String> { 875 992 match self.resolve_ref(path, current_namespace)? { 876 993 ResolvedRef::Local { .. } => Some(current_namespace.to_string()), 877 994 ResolvedRef::External { namespace, .. } ··· 903 1020 } 904 1021 } 905 1022 906 - fn resolve_use_statement(&mut self, current_namespace: &str, use_stmt: &Use) -> Result<(), ValidationErrors> { 1023 + fn resolve_use_statement( 1024 + &mut self, 1025 + current_namespace: &str, 1026 + use_stmt: &Use, 1027 + ) -> Result<(), ValidationErrors> { 907 1028 let mut errors = ValidationErrors::new(); 908 1029 909 1030 // Determine if this is: ··· 921 1042 // In this case, path has >=2 segments and items has 1 item whose name matches the last segment 922 1043 if items.len() == 1 923 1044 && use_stmt.path.segments.len() >= 2 924 - && items[0].name.name == use_stmt.path.segments.last().unwrap().name { 1045 + && items[0].name.name == use_stmt.path.segments.last().unwrap().name 1046 + { 925 1047 // Old syntax: use namespace.typename as alias 926 1048 let namespace = use_stmt.path.segments[..use_stmt.path.segments.len() - 1] 927 1049 .iter() ··· 938 1060 939 1061 // Check if the target namespace exists or if there are modules with that prefix 940 1062 let namespace_exists = self.modules.contains_key(&target_namespace); 941 - let has_children = self.modules.keys().any(|ns| ns.starts_with(&alloc::format!("{}.", target_namespace))); 1063 + let has_children = self 1064 + .modules 1065 + .keys() 1066 + .any(|ns| ns.starts_with(&alloc::format!("{}.", target_namespace))); 942 1067 943 1068 if !namespace_exists && !has_children { 944 1069 errors.push(ValidationError::UndefinedReference { ··· 954 1079 // Check for implicit main resolution 955 1080 // If namespace suffix matches a type name, import only that type 956 1081 // Otherwise, this is a namespace alias for path shortening 957 - let namespace_suffix = target_namespace.split('.').last().unwrap_or(&target_namespace); 1082 + let namespace_suffix = target_namespace 1083 + .split('.') 1084 + .last() 1085 + .unwrap_or(&target_namespace); 958 1086 959 1087 if let Some(target_module) = self.modules.get(&target_namespace) { 960 1088 // Module exists - check if there's a type matching the namespace suffix ··· 983 1111 UseImports::All => { 984 1112 // Check for implicit main resolution 985 1113 // If namespace suffix matches a type name, import only that type 986 - let namespace_suffix = target_namespace.split('.').last().unwrap_or(&target_namespace); 1114 + let namespace_suffix = target_namespace 1115 + .split('.') 1116 + .last() 1117 + .unwrap_or(&target_namespace); 987 1118 988 1119 if let Some(target_module) = self.modules.get(&target_namespace) { 989 1120 // Check if there's a type matching the namespace suffix 990 1121 if target_module.symbols.types.contains_key(namespace_suffix) { 991 1122 // Implicit main resolution: import only the type matching the namespace suffix 992 1123 let imported = ImportedSymbol { 993 - original_path: use_stmt.path.segments.iter() 1124 + original_path: use_stmt 1125 + .path 1126 + .segments 1127 + .iter() 994 1128 .map(|s| s.name.clone()) 995 1129 .collect(), 996 1130 local_name: namespace_suffix.to_string(), ··· 1012 1146 let mut imports = Vec::new(); 1013 1147 1014 1148 // Get the namespace suffix for main resolution 1015 - let namespace_suffix = target_namespace.split('.').last().unwrap_or(&target_namespace); 1149 + let namespace_suffix = target_namespace 1150 + .split('.') 1151 + .last() 1152 + .unwrap_or(&target_namespace); 1016 1153 1017 1154 for item in items { 1018 1155 // Determine the actual type name to look up ··· 1047 1184 let original_path = if is_single_type_import { 1048 1185 // Old syntax: use namespace.typename as alias 1049 1186 // Path segments already include the type name 1050 - use_stmt.path.segments.iter() 1187 + use_stmt 1188 + .path 1189 + .segments 1190 + .iter() 1051 1191 .map(|s| s.name.clone()) 1052 1192 .collect() 1053 1193 } else { 1054 1194 // New syntax: use namespace { items }; 1055 1195 // Need to append the type name to the namespace 1056 - use_stmt.path.segments.iter() 1196 + use_stmt 1197 + .path 1198 + .segments 1199 + .iter() 1057 1200 .map(|s| s.name.clone()) 1058 1201 .chain(core::iter::once(type_name.clone())) 1059 1202 .collect() ··· 1080 1223 1081 1224 // Add namespace alias if one was created 1082 1225 if let Some((alias, full_namespace)) = namespace_alias_to_add { 1083 - module.imports.namespace_aliases.insert(alias, full_namespace); 1226 + module 1227 + .imports 1228 + .namespace_aliases 1229 + .insert(alias, full_namespace); 1084 1230 } 1085 1231 1086 1232 if errors.is_empty() { ··· 1094 1240 annotations.iter().any(|ann| ann.name.name == "main") 1095 1241 } 1096 1242 1097 - fn build_symbol_table(namespace: &str, lexicon: &Lexicon) -> Result<SymbolTable, ValidationErrors> { 1243 + fn build_symbol_table( 1244 + namespace: &str, 1245 + lexicon: &Lexicon, 1246 + ) -> Result<SymbolTable, ValidationErrors> { 1098 1247 let mut symbols = SymbolTable { 1099 1248 types: BTreeMap::new(), 1100 1249 }; ··· 1119 1268 }; 1120 1269 1121 1270 if let Some(name) = name { 1122 - items_by_name.entry(name.to_string()).or_insert_with(Vec::new).push(item); 1271 + items_by_name 1272 + .entry(name.to_string()) 1273 + .or_insert_with(Vec::new) 1274 + .push(item); 1123 1275 } 1124 1276 } 1125 1277 ··· 1253 1405 } 1254 1406 1255 1407 // Check @main annotations 1256 - let items_with_main: Vec<&Item> = items.iter() 1408 + let items_with_main: Vec<&Item> = items 1409 + .iter() 1257 1410 .filter(|item| { 1258 1411 let annotations = match item { 1259 1412 Item::Record(r) => &r.annotations, ··· 1415 1568 } 1416 1569 } 1417 1570 1418 - fn resolve_inline_type(&mut self, namespace: &str, inline_type: &InlineType) -> Result<(), ValidationErrors> { 1571 + fn resolve_inline_type( 1572 + &mut self, 1573 + namespace: &str, 1574 + inline_type: &InlineType, 1575 + ) -> Result<(), ValidationErrors> { 1419 1576 self.resolve_type(namespace, &inline_type.ty) 1420 1577 } 1421 1578 1422 - fn resolve_def_type(&mut self, namespace: &str, def_type: &DefType) -> Result<(), ValidationErrors> { 1579 + fn resolve_def_type( 1580 + &mut self, 1581 + namespace: &str, 1582 + def_type: &DefType, 1583 + ) -> Result<(), ValidationErrors> { 1423 1584 self.resolve_type(namespace, &def_type.ty) 1424 1585 } 1425 1586 ··· 1455 1616 } 1456 1617 } 1457 1618 1458 - fn resolve_procedure(&mut self, namespace: &str, procedure: &Procedure) -> Result<(), ValidationErrors> { 1619 + fn resolve_procedure( 1620 + &mut self, 1621 + namespace: &str, 1622 + procedure: &Procedure, 1623 + ) -> Result<(), ValidationErrors> { 1459 1624 let mut errors = ValidationErrors::new(); 1460 1625 1461 1626 for param in &procedure.params { ··· 1487 1652 } 1488 1653 } 1489 1654 1490 - fn resolve_subscription(&mut self, namespace: &str, subscription: &Subscription) -> Result<(), ValidationErrors> { 1655 + fn resolve_subscription( 1656 + &mut self, 1657 + namespace: &str, 1658 + subscription: &Subscription, 1659 + ) -> Result<(), ValidationErrors> { 1491 1660 let mut errors = ValidationErrors::new(); 1492 1661 1493 1662 for param in &subscription.params { ··· 1512 1681 fn resolve_type(&mut self, namespace: &str, ty: &Type) -> Result<(), ValidationErrors> { 1513 1682 match ty { 1514 1683 Type::Primitive { .. } | Type::Unknown { .. } => Ok(()), 1515 - Type::Reference { path, span } => { 1516 - self.resolve_reference(namespace, path, *span) 1517 - } 1518 - Type::Array { inner, .. } => { 1519 - self.resolve_type(namespace, inner) 1520 - } 1684 + Type::Reference { path, span } => self.resolve_reference(namespace, path, *span), 1685 + Type::Array { inner, .. } => self.resolve_type(namespace, inner), 1521 1686 Type::Union { types, .. } => { 1522 1687 let mut errors = ValidationErrors::new(); 1523 1688 for ty in types { ··· 1544 1709 Err(errors) 1545 1710 } 1546 1711 } 1547 - Type::Parenthesized { inner, .. } => { 1548 - self.resolve_type(namespace, inner) 1549 - } 1550 - Type::Constrained { base, .. } => { 1551 - self.resolve_type(namespace, base) 1552 - } 1712 + Type::Parenthesized { inner, .. } => self.resolve_type(namespace, inner), 1713 + Type::Constrained { base, .. } => self.resolve_type(namespace, base), 1553 1714 } 1554 1715 } 1555 1716 1556 - fn resolve_reference(&mut self, current_namespace: &str, path: &Path, span: Span) -> Result<(), ValidationErrors> { 1717 + fn resolve_reference( 1718 + &mut self, 1719 + current_namespace: &str, 1720 + path: &Path, 1721 + span: Span, 1722 + ) -> Result<(), ValidationErrors> { 1557 1723 let full_path = path.to_string(); 1558 1724 1559 1725 if path.segments.len() == 1 { ··· 1643 1809 alloc::format!("{}.{}", target_namespace, type_name) 1644 1810 }; 1645 1811 if let Some(module) = self.modules.get(&expanded_full_path) { 1646 - let namespace_suffix = expanded_full_path.split('.').last().unwrap_or(&expanded_full_path); 1812 + let namespace_suffix = expanded_full_path 1813 + .split('.') 1814 + .last() 1815 + .unwrap_or(&expanded_full_path); 1647 1816 if namespace_suffix == type_name && module.symbols.types.contains_key(type_name) { 1648 1817 return Ok(()); 1649 1818 } ··· 1734 1903 let a = parse_lexicon("record post {}").unwrap(); 1735 1904 ws.add_module("app.bsky.feed".into(), a).unwrap(); 1736 1905 1737 - let b = parse_lexicon("use app.bsky.feed.post as FeedPost; record like { subject: FeedPost, }").unwrap(); 1906 + let b = 1907 + parse_lexicon("use app.bsky.feed.post as FeedPost; record like { subject: FeedPost, }") 1908 + .unwrap(); 1738 1909 ws.add_module("app.bsky.feed.like".into(), b).unwrap(); 1739 1910 1740 1911 let result = ws.resolve(); ··· 1880 2051 let result = ws.resolve(); 1881 2052 assert!(result.is_err()); 1882 2053 let errors = result.unwrap_err(); 1883 - assert!(errors.errors.iter().any(|e| matches!(e, ValidationError::ConstraintTooPermissive { .. }))); 2054 + assert!( 2055 + errors 2056 + .errors 2057 + .iter() 2058 + .any(|e| matches!(e, ValidationError::ConstraintTooPermissive { .. })) 2059 + ); 1884 2060 } 1885 2061 1886 2062 #[test] ··· 2142 2318 let result = ws.add_module("com.example.thread".into(), lexicon); 2143 2319 assert!(result.is_err()); 2144 2320 let errors = result.unwrap_err(); 2145 - assert!(errors.errors.iter().any(|e| matches!(e, ValidationError::AmbiguousMain { .. }))); 2321 + assert!( 2322 + errors 2323 + .errors 2324 + .iter() 2325 + .any(|e| matches!(e, ValidationError::AmbiguousMain { .. })) 2326 + ); 2146 2327 } 2147 2328 2148 2329 #[test] ··· 2164 2345 let result = ws.add_module("com.example.thread".into(), lexicon); 2165 2346 assert!(result.is_err()); 2166 2347 let errors = result.unwrap_err(); 2167 - assert!(errors.errors.iter().any(|e| matches!(e, ValidationError::MultipleMain { .. }))); 2348 + assert!( 2349 + errors 2350 + .errors 2351 + .iter() 2352 + .any(|e| matches!(e, ValidationError::MultipleMain { .. })) 2353 + ); 2168 2354 } 2169 2355 2170 2356 #[test] ··· 2185 2371 let result = ws.add_module("com.example.thread".into(), lexicon); 2186 2372 assert!(result.is_err()); 2187 2373 let errors = result.unwrap_err(); 2188 - assert!(errors.errors.iter().any(|e| matches!(e, ValidationError::ConflictNotAllowed { .. }))); 2374 + assert!( 2375 + errors 2376 + .errors 2377 + .iter() 2378 + .any(|e| matches!(e, ValidationError::ConflictNotAllowed { .. })) 2379 + ); 2189 2380 } 2190 2381 2191 2382 #[test] ··· 2203 2394 let result = ws.add_module("com.example.thread".into(), lexicon); 2204 2395 assert!(result.is_err()); 2205 2396 let errors = result.unwrap_err(); 2206 - assert!(errors.errors.iter().any(|e| matches!(e, ValidationError::DuplicateDefinition { .. }))); 2397 + assert!( 2398 + errors 2399 + .errors 2400 + .iter() 2401 + .any(|e| matches!(e, ValidationError::DuplicateDefinition { .. })) 2402 + ); 2207 2403 } 2208 2404 2209 2405 #[test] ··· 2278 2474 2279 2475 // Create a namespace where the namespace suffix matches a type name 2280 2476 // No @main annotation needed for implicit main resolution 2281 - let profile_ns = parse_lexicon(r#" 2477 + let profile_ns = parse_lexicon( 2478 + r#" 2282 2479 def type color = { 2283 2480 red!: integer, 2284 2481 green!: integer, ··· 2288 2485 record profile { 2289 2486 color: color, 2290 2487 } 2291 - "#).unwrap(); 2292 - ws.add_module("place.stream.chat.profile".into(), profile_ns).unwrap(); 2488 + "#, 2489 + ) 2490 + .unwrap(); 2491 + ws.add_module("place.stream.chat.profile".into(), profile_ns) 2492 + .unwrap(); 2293 2493 2294 2494 // Import using implicit main resolution - should only import profile, not color 2295 - let bookmark = parse_lexicon(r#" 2495 + let bookmark = parse_lexicon( 2496 + r#" 2296 2497 use place.stream.chat.profile; 2297 2498 2298 2499 record bookmark { 2299 2500 owner!: profile, 2300 2501 } 2301 - "#).unwrap(); 2302 - ws.add_module("com.example.bookmark".into(), bookmark).unwrap(); 2502 + "#, 2503 + ) 2504 + .unwrap(); 2505 + ws.add_module("com.example.bookmark".into(), bookmark) 2506 + .unwrap(); 2303 2507 2304 2508 // Should resolve without unused import warning for color 2305 2509 let result = ws.resolve(); ··· 2316 2520 let mut ws = Workspace::new(); 2317 2521 2318 2522 // Create a namespace where the suffix doesn't match a type name 2319 - let defs = parse_lexicon(r#" 2523 + let defs = parse_lexicon( 2524 + r#" 2320 2525 def type foo = string; 2321 2526 def type bar = integer; 2322 - "#).unwrap(); 2527 + "#, 2528 + ) 2529 + .unwrap(); 2323 2530 ws.add_module("com.example.defs".into(), defs).unwrap(); 2324 2531 2325 2532 // Using "use com.example;" creates a namespace alias "example" -> "com.example" 2326 2533 // This allows referencing types via the shortened path 2327 - let app = parse_lexicon(r#" 2534 + let app = parse_lexicon( 2535 + r#" 2328 2536 use com.example; 2329 2537 2330 2538 record thing { 2331 2539 x!: example.defs.foo, 2332 2540 y!: example.defs.bar, 2333 2541 } 2334 - "#).unwrap(); 2542 + "#, 2543 + ) 2544 + .unwrap(); 2335 2545 ws.add_module("com.example.app".into(), app).unwrap(); 2336 2546 2337 2547 // Should resolve successfully using the namespace alias ··· 2351 2561 let mut ws = Workspace::new(); 2352 2562 2353 2563 // Create nested namespaces 2354 - let actor_defs = parse_lexicon(r#" 2564 + let actor_defs = parse_lexicon( 2565 + r#" 2355 2566 def type profileView = { 2356 2567 did!: string, 2357 2568 handle!: string, 2358 2569 }; 2359 - "#).unwrap(); 2360 - ws.add_module("app.bsky.actor.defs".into(), actor_defs).unwrap(); 2570 + "#, 2571 + ) 2572 + .unwrap(); 2573 + ws.add_module("app.bsky.actor.defs".into(), actor_defs) 2574 + .unwrap(); 2361 2575 2362 - let feed_post = parse_lexicon(r#" 2576 + let feed_post = parse_lexicon( 2577 + r#" 2363 2578 def type post = { 2364 2579 text!: string, 2365 2580 }; 2366 - "#).unwrap(); 2367 - ws.add_module("app.bsky.feed.post".into(), feed_post).unwrap(); 2581 + "#, 2582 + ) 2583 + .unwrap(); 2584 + ws.add_module("app.bsky.feed.post".into(), feed_post) 2585 + .unwrap(); 2368 2586 2369 2587 // Use namespace alias to shorten references 2370 - let like = parse_lexicon(r#" 2588 + let like = parse_lexicon( 2589 + r#" 2371 2590 use app.bsky; 2372 2591 2373 2592 record like { 2374 2593 subject!: bsky.feed.post, 2375 2594 actor!: bsky.actor.defs.profileView, 2376 2595 } 2377 - "#).unwrap(); 2596 + "#, 2597 + ) 2598 + .unwrap(); 2378 2599 ws.add_module("app.bsky.feed.like".into(), like).unwrap(); 2379 2600 2380 2601 let result = ws.resolve(); ··· 2389 2610 let mut ws = Workspace::new(); 2390 2611 2391 2612 // Create a namespace where the suffix doesn't match a type name 2392 - let defs = parse_lexicon(r#" 2613 + let defs = parse_lexicon( 2614 + r#" 2393 2615 def type foo = string; 2394 2616 def type bar = integer; 2395 - "#).unwrap(); 2617 + "#, 2618 + ) 2619 + .unwrap(); 2396 2620 ws.add_module("com.example.defs".into(), defs).unwrap(); 2397 2621 2398 2622 // Using "use com.example.defs;" where "defs" is not a type name 2399 2623 // creates a namespace alias "defs" -> "com.example.defs" 2400 - let app = parse_lexicon(r#" 2624 + let app = parse_lexicon( 2625 + r#" 2401 2626 use com.example.defs; 2402 2627 2403 2628 record thing { 2404 2629 x!: defs.foo, 2405 2630 y!: defs.bar, 2406 2631 } 2407 - "#).unwrap(); 2632 + "#, 2633 + ) 2634 + .unwrap(); 2408 2635 ws.add_module("com.example.app".into(), app).unwrap(); 2409 2636 2410 2637 // Should resolve successfully using the namespace alias
+6 -3
mlf-lang/tests/integration_test.rs
··· 3 3 // `test.mlf` (plus optional support files) and an `expected.json`. 4 4 5 5 use mlf_integration_tests::test_utils; 6 - use mlf_lang::{parser::parse_lexicon, Workspace}; 6 + use mlf_lang::{Workspace, parser::parse_lexicon}; 7 7 use serde::Deserialize; 8 8 use std::collections::HashMap; 9 9 use std::fs; ··· 35 35 } 36 36 37 37 fn run_lang_test(test_mlf: &Path) -> datatest_stable::Result<()> { 38 - let test_dir = test_mlf.parent().ok_or("test.mlf has no parent directory")?; 38 + let test_dir = test_mlf 39 + .parent() 40 + .ok_or("test.mlf has no parent directory")?; 39 41 let test_name = test_dir 40 42 .file_name() 41 43 .and_then(|s| s.to_str()) ··· 52 54 let expected_json = fs::read_to_string(&expected_path)?; 53 55 let expected: ExpectedResult = serde_json::from_str(&expected_json)?; 54 56 55 - let mut ws = Workspace::with_std().map_err(|e| format!("Failed to create workspace: {:?}", e))?; 57 + let mut ws = 58 + Workspace::with_std().map_err(|e| format!("Failed to create workspace: {:?}", e))?; 56 59 57 60 // Support files load before test.mlf so cross-module refs resolve. 58 61 let mut module_files: Vec<(String, PathBuf)> = config
+3 -4
mlf-lexicon-fetcher/Cargo.toml
··· 5 5 license = "MIT" 6 6 7 7 [dependencies] 8 - hickory-resolver = "0.24" 9 - thiserror = "2.0" 10 - serde = { version = "1.0", features = ["derive"] } 8 + mlf-atproto = { path = "../mlf-atproto" } 9 + async-trait = "0.1" 11 10 serde_json = "1.0" 12 11 reqwest = { version = "0.12", features = ["json"] } 13 - async-trait = "0.1" 12 + thiserror = "2.0" 14 13 tokio = { version = "1", features = ["rt"] } 15 14 16 15 [dev-dependencies]
+17 -4
mlf-lexicon-fetcher/examples/usage.rs
··· 9 9 println!("=== Example 1: Fetch with Metadata ==="); 10 10 11 11 let mut dns_resolver = MockDnsResolver::new(); 12 - dns_resolver.add_record("place.stream", "chat.profile", "did:plc:test123".to_string()); 12 + dns_resolver.add_record( 13 + "place.stream", 14 + "chat.profile", 15 + "did:plc:test123".to_string(), 16 + ); 13 17 14 18 let mut http_client = MockHttpClient::new(); 15 19 http_client.add_lexicon( ··· 29 33 let fetcher = LexiconFetcher::new(dns_resolver, http_client); 30 34 31 35 // New API returns metadata (DID, NSID, lexicon) 32 - match fetcher.fetch_with_metadata("place.stream.chat.profile").await { 36 + match fetcher 37 + .fetch_with_metadata("place.stream.chat.profile") 38 + .await 39 + { 33 40 Ok(result) => { 34 41 println!("Successfully fetched {} lexicon(s):", result.lexicons.len()); 35 42 for fetched in result.lexicons { 36 43 println!(" NSID: {}", fetched.nsid); 37 44 println!(" DID: {}", fetched.did); 38 - println!(" Lexicon: {}", serde_json::to_string_pretty(&fetched.lexicon)?); 45 + println!( 46 + " Lexicon: {}", 47 + serde_json::to_string_pretty(&fetched.lexicon)? 48 + ); 39 49 } 40 50 } 41 51 Err(e) => eprintln!("Error: {}", e), ··· 120 130 let fetcher4 = LexiconFetcher::new(dns_resolver4, http_client4); 121 131 122 132 // Skip DNS resolution when DID is already known (e.g., from lockfile) 123 - match fetcher4.fetch_from_did_with_metadata("did:plc:bsky123", "app.bsky.feed.post").await { 133 + match fetcher4 134 + .fetch_from_did_with_metadata("did:plc:bsky123", "app.bsky.feed.post") 135 + .await 136 + { 124 137 Ok(result) => { 125 138 println!("Fetched from known DID:"); 126 139 for fetched in result.lexicons {
+171 -584
mlf-lexicon-fetcher/src/lib.rs
··· 1 - // MLF Lexicon Fetcher 2 - // Resolves ATProto lexicon NSIDs to DIDs via DNS TXT records 3 - // and fetches lexicon JSON via HTTP 1 + //! MLF lexicon fetcher. 2 + //! 3 + //! Read-side domain: given an NSID (or wildcard pattern), resolve it to 4 + //! the publishing DID via `_lexicon.<authority>` TXT and fetch the 5 + //! corresponding `com.atproto.lexicon.schema` record(s) from the PDS. 6 + //! 7 + //! Low-level primitives (DID parsing, DNS resolution, XRPC calls) live 8 + //! in the `mlf-atproto` crate; this crate wraps them in NSID-aware 9 + //! pattern-matching and lockfile-friendly result types. 4 10 5 11 use async_trait::async_trait; 6 - use hickory_resolver::config::{ResolverConfig, ResolverOpts}; 7 - use hickory_resolver::TokioAsyncResolver; 8 - use serde::Deserialize; 12 + use mlf_atproto::identity::{self, IdentityError}; 13 + use mlf_atproto::records::{self, RecordError}; 14 + use mlf_atproto::xrpc::XrpcError; 9 15 use std::collections::HashMap; 10 16 use std::sync::{Arc, Mutex}; 11 17 use thiserror::Error; 12 18 13 - #[derive(Debug, Deserialize)] 14 - struct AtProtoRecord { 15 - uri: String, 16 - value: serde_json::Value, 17 - } 19 + // Re-export identity primitives that existing callers depend on. 20 + pub use mlf_atproto::identity::{ 21 + DnsResolver, MockDnsResolver, RealDnsResolver, construct_dns_name, parse_nsid, 22 + }; 18 23 19 24 #[derive(Error, Debug)] 20 25 pub enum LexiconFetcherError { 21 - #[error("Failed to create DNS resolver: {0}")] 22 - ResolverCreationFailed(String), 26 + #[error("Identity resolution failed: {0}")] 27 + Identity(#[from] IdentityError), 23 28 24 - #[error("DNS lookup failed for {domain}: {error}")] 25 - LookupFailed { domain: String, error: String }, 29 + #[error("Record fetch failed: {0}")] 30 + Records(#[from] RecordError), 26 31 27 - #[error("No DID found in TXT record for {0}")] 28 - NoDid(String), 32 + #[error("XRPC call failed: {0}")] 33 + Xrpc(#[from] XrpcError), 29 34 30 35 #[error("Invalid NSID format: {0}")] 31 36 InvalidNsid(String), 32 37 33 - #[error("HTTP request failed: {0}")] 34 - HttpRequestFailed(String), 35 - 36 - #[error("Failed to parse JSON response: {0}")] 37 - JsonParseFailed(String), 38 - 39 38 #[error("Lexicon not found: {0}")] 40 39 LexiconNotFound(String), 41 40 42 - #[error("Invalid URL: {0}")] 43 - InvalidUrl(String), 41 + #[error("Could not extract NSID from record: {0}")] 42 + MalformedRecord(String), 44 43 } 45 44 46 45 pub type Result<T> = std::result::Result<T, LexiconFetcherError>; 47 46 48 - /// Trait for DNS resolution - allows mocking in tests 49 - #[async_trait] 50 - pub trait DnsResolver: Send + Sync { 51 - /// Resolve an NSID to a DID via DNS TXT lookup 52 - async fn resolve_lexicon_did(&self, authority: &str, name_segments: &str) -> Result<String>; 53 - } 54 - 55 - /// Real DNS resolver using hickory_resolver's async resolver 56 - pub struct RealDnsResolver { 57 - resolver: TokioAsyncResolver, 58 - } 59 - 60 - impl RealDnsResolver { 61 - pub async fn new() -> Result<Self> { 62 - let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()); 63 - Ok(Self { resolver }) 64 - } 65 - 66 - pub async fn with_config(config: ResolverConfig, opts: ResolverOpts) -> Result<Self> { 67 - let resolver = TokioAsyncResolver::tokio(config, opts); 68 - Ok(Self { resolver }) 69 - } 70 - } 71 - 72 - #[async_trait] 73 - impl DnsResolver for RealDnsResolver { 74 - async fn resolve_lexicon_did(&self, authority: &str, name_segments: &str) -> Result<String> { 75 - let dns_name = construct_dns_name(authority, name_segments); 76 - 77 - // Lookup TXT records (async) 78 - let response = self 79 - .resolver 80 - .txt_lookup(&dns_name) 81 - .await 82 - .map_err(|e| LexiconFetcherError::LookupFailed { 83 - domain: dns_name.clone(), 84 - error: e.to_string(), 85 - })?; 86 - 87 - // Parse TXT records to find DID 88 - for txt_record in response.iter() { 89 - for txt_data in txt_record.txt_data() { 90 - let text = String::from_utf8_lossy(txt_data); 91 - // Look for "did=did:plc:..." or "did=did:web:..." 92 - if let Some(did_value) = text.strip_prefix("did=") { 93 - return Ok(did_value.trim().to_string()); 94 - } 95 - } 96 - } 97 - 98 - Err(LexiconFetcherError::NoDid(dns_name)) 99 - } 100 - } 101 - 102 - /// Mock DNS resolver for testing 103 - #[derive(Clone)] 104 - pub struct MockDnsResolver { 105 - records: Arc<Mutex<HashMap<String, String>>>, 106 - } 107 - 108 - impl MockDnsResolver { 109 - pub fn new() -> Self { 110 - Self { 111 - records: Arc::new(Mutex::new(HashMap::new())), 112 - } 113 - } 114 - 115 - /// Add a mock DNS record (maps NSID authority+name to DID) 116 - pub fn add_record(&mut self, authority: &str, name_segments: &str, did: String) { 117 - let dns_name = construct_dns_name(authority, name_segments); 118 - self.records.lock().unwrap().insert(dns_name, did); 119 - } 120 - 121 - /// Add a mock record using full NSID 122 - pub fn add_record_from_nsid(&mut self, nsid: &str, did: String) -> Result<()> { 123 - let (authority, name_segments) = parse_nsid(nsid)?; 124 - self.add_record(&authority, &name_segments, did); 125 - Ok(()) 126 - } 127 - } 128 - 129 - impl Default for MockDnsResolver { 130 - fn default() -> Self { 131 - Self::new() 132 - } 133 - } 134 - 135 - #[async_trait] 136 - impl DnsResolver for MockDnsResolver { 137 - async fn resolve_lexicon_did(&self, authority: &str, name_segments: &str) -> Result<String> { 138 - let dns_name = construct_dns_name(authority, name_segments); 139 - self.records 140 - .lock() 141 - .unwrap() 142 - .get(&dns_name) 143 - .cloned() 144 - .ok_or_else(|| LexiconFetcherError::LookupFailed { 145 - domain: dns_name.clone(), 146 - error: "No mock record found".to_string(), 147 - }) 148 - } 149 - } 150 - 151 - /// Construct DNS name from authority and name segments 152 - /// For "app.bsky" + "actor": "_lexicon.actor.bsky.app" 153 - /// For "place.stream" + "key": "_lexicon.key.stream.place" 154 - pub fn construct_dns_name(authority: &str, name_segments: &str) -> String { 155 - let auth_parts: Vec<&str> = authority.split('.').collect(); 156 - let reversed_auth: Vec<&str> = auth_parts.iter().rev().copied().collect(); 157 - 158 - if name_segments.is_empty() { 159 - // No name segments, just use reversed authority 160 - // For "place.stream": "_lexicon.stream.place" 161 - format!("_lexicon.{}", reversed_auth.join(".")) 162 - } else { 163 - // Prepend name segments before reversed authority 164 - // For "app.bsky" + "actor": "_lexicon.actor.bsky.app" 165 - format!("_lexicon.{}.{}", name_segments, reversed_auth.join(".")) 166 - } 167 - } 168 - 169 - /// Parse NSID into authority and name segments 170 - /// For "place.stream.key", returns ("place.stream", "key") 171 - /// For "app.bsky.actor.profile", returns ("app.bsky", "actor.profile") 172 - pub fn parse_nsid(nsid: &str) -> Result<(String, String)> { 173 - // NSID format: authority.name(.name)* 174 - // Authority is first 2 segments (reversed domain) 175 - let parts: Vec<&str> = nsid.split('.').collect(); 176 - 177 - if parts.len() < 2 { 178 - return Err(LexiconFetcherError::InvalidNsid(format!( 179 - "NSID must have at least 2 segments: {}", 180 - nsid 181 - ))); 182 - } 183 - 184 - // Authority is first 2 segments 185 - let authority = format!("{}.{}", parts[0], parts[1]); 186 - 187 - // Name segments are everything after the authority 188 - let name_segments = if parts.len() > 2 { 189 - parts[2..].join(".") 190 - } else { 191 - String::new() 192 - }; 193 - 194 - Ok((authority, name_segments)) 195 - } 196 - 197 - /// Trait for HTTP client - allows mocking in tests 47 + /// Trait for the HTTP side of lexicon fetching. 198 48 #[async_trait] 199 49 pub trait HttpClient: Send + Sync { 200 - /// Fetch a single lexicon by NSID from a DID's server 50 + /// Fetch a single lexicon by NSID from a DID's repo. 201 51 async fn fetch_lexicon(&self, did: &str, nsid: &str) -> Result<serde_json::Value>; 202 52 203 - /// Fetch all lexicons matching a pattern (e.g., "place.stream.*") 53 + /// Fetch all lexicons under a wildcard pattern. 204 54 async fn fetch_lexicons_pattern( 205 55 &self, 206 56 did: &str, ··· 208 58 ) -> Result<Vec<(String, serde_json::Value)>>; 209 59 } 210 60 211 - /// Real HTTP client using reqwest 61 + /// Production HTTP client for fetching `com.atproto.lexicon.schema` records. 212 62 pub struct RealHttpClient { 213 63 client: reqwest::Client, 214 64 } ··· 222 72 223 73 pub fn with_client(client: reqwest::Client) -> Self { 224 74 Self { client } 75 + } 76 + 77 + async fn fetch_all_schema_records(&self, did: &str) -> Result<Vec<records::Record>> { 78 + let pds = identity::resolve_did_to_pds(&self.client, did).await?; 79 + records::list_all_records(&self.client, &pds, did, "com.atproto.lexicon.schema") 80 + .await 81 + .map_err(Into::into) 225 82 } 226 83 } 227 84 ··· 234 91 #[async_trait] 235 92 impl HttpClient for RealHttpClient { 236 93 async fn fetch_lexicon(&self, did: &str, nsid: &str) -> Result<serde_json::Value> { 237 - // Fetch all records from the DID's repo 238 - let records = self.fetch_records_from_did(did).await?; 239 - 240 - // Find the specific NSID 94 + let records = self.fetch_all_schema_records(did).await?; 241 95 for record in records { 242 96 let record_nsid = extract_nsid_from_record(&record)?; 243 97 if record_nsid == nsid { 244 98 return Ok(record.value); 245 99 } 246 100 } 247 - 248 101 Err(LexiconFetcherError::LexiconNotFound(format!( 249 - "Lexicon {} not found in repo {}", 250 - nsid, did 102 + "Lexicon {nsid} not found in repo {did}" 251 103 ))) 252 104 } 253 105 ··· 256 108 did: &str, 257 109 pattern: &str, 258 110 ) -> Result<Vec<(String, serde_json::Value)>> { 259 - // Fetch all records from the DID's repo 260 - let records = self.fetch_records_from_did(did).await?; 261 - 111 + let records = self.fetch_all_schema_records(did).await?; 262 112 let mut results = Vec::new(); 263 - 264 - // Handle exact match (no wildcard) 265 - if !pattern.contains('*') && !pattern.contains('_') { 266 - for record in records { 267 - let record_nsid = extract_nsid_from_record(&record)?; 268 - if record_nsid == pattern { 269 - results.push((record_nsid, record.value)); 270 - } 113 + for record in records { 114 + let record_nsid = extract_nsid_from_record(&record)?; 115 + if nsid_matches_pattern(&record_nsid, pattern) { 116 + results.push((record_nsid, record.value)); 271 117 } 272 - return Ok(results); 273 118 } 274 - 275 - // Handle wildcard patterns 276 - if pattern.ends_with(".*") { 277 - // "*" matches EVERYTHING 278 - // For "place.stream.*", match all: place.stream.chat, place.stream.chat.profile, etc. 279 - let base = pattern.strip_suffix(".*").unwrap(); 280 - let prefix_with_dot = format!("{}.", base); 281 - 282 - for record in records { 283 - let record_nsid = extract_nsid_from_record(&record)?; 284 - if record_nsid.starts_with(&prefix_with_dot) { 285 - results.push((record_nsid, record.value)); 286 - } 287 - } 288 - } else if pattern.ends_with("._") { 289 - // "_" matches only DIRECT CHILDREN 290 - // For "place.stream._", match place.stream.chat but NOT place.stream.chat.profile 291 - let base = pattern.strip_suffix("._").unwrap(); 292 - let prefix_with_dot = format!("{}.", base); 293 - 294 - for record in records { 295 - let record_nsid = extract_nsid_from_record(&record)?; 296 - 297 - if let Some(suffix) = record_nsid.strip_prefix(&prefix_with_dot) { 298 - // Check if it's a direct child (no more dots in the suffix) 299 - if !suffix.contains('.') && !suffix.is_empty() { 300 - results.push((record_nsid, record.value)); 301 - } 302 - } 303 - } 304 - } 305 - 306 119 Ok(results) 307 120 } 308 121 } 309 122 310 - impl RealHttpClient { 311 - /// Fetch all lexicon records from a DID's ATProto repository 312 - async fn fetch_records_from_did(&self, did: &str) -> Result<Vec<AtProtoRecord>> { 313 - // Resolve DID to PDS URL 314 - let pds_url = self.resolve_did_to_pds(did).await?; 315 - 316 - let mut all_records = Vec::new(); 317 - let mut cursor: Option<String> = None; 318 - 319 - // Paginate through all records 320 - loop { 321 - let url = if let Some(ref c) = cursor { 322 - format!( 323 - "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection=com.atproto.lexicon.schema&cursor={}", 324 - pds_url, did, c 325 - ) 326 - } else { 327 - format!( 328 - "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection=com.atproto.lexicon.schema", 329 - pds_url, did 330 - ) 331 - }; 332 - 333 - let response = self 334 - .client 335 - .get(&url) 336 - .send() 337 - .await 338 - .map_err(|e| LexiconFetcherError::HttpRequestFailed(e.to_string()))?; 339 - 340 - if !response.status().is_success() { 341 - return Err(LexiconFetcherError::HttpRequestFailed(format!( 342 - "HTTP {} when fetching records from {}", 343 - response.status(), 344 - did 345 - ))); 346 - } 347 - 348 - let mut list_response: serde_json::Value = response 349 - .json() 350 - .await 351 - .map_err(|e| LexiconFetcherError::JsonParseFailed(e.to_string()))?; 352 - 353 - // Extract records 354 - if let Some(records_array) = list_response.get_mut("records") { 355 - if let Some(records) = records_array.as_array_mut() { 356 - for record_value in records.drain(..) { 357 - let record: AtProtoRecord = serde_json::from_value(record_value) 358 - .map_err(|e| LexiconFetcherError::JsonParseFailed(format!("Failed to parse record: {}", e)))?; 359 - all_records.push(record); 360 - } 361 - } 362 - } 363 - 364 - // Check for pagination cursor 365 - cursor = list_response 366 - .get("cursor") 367 - .and_then(|c| c.as_str()) 368 - .map(|s| s.to_string()); 369 - 370 - if cursor.is_none() { 371 - break; 372 - } 373 - } 374 - 375 - Ok(all_records) 376 - } 377 - 378 - /// Resolve a DID to its PDS URL 379 - async fn resolve_did_to_pds(&self, did: &str) -> Result<String> { 380 - // For did:web:, extract the domain 381 - if let Some(domain) = did.strip_prefix("did:web:") { 382 - return Ok(format!("https://{}", domain)); 383 - } 384 - 385 - // For did:plc:, query the PLC directory 386 - if did.starts_with("did:plc:") { 387 - let url = format!("https://plc.directory/{}", did); 388 - 389 - let response = self 390 - .client 391 - .get(&url) 392 - .send() 393 - .await 394 - .map_err(|e| LexiconFetcherError::HttpRequestFailed(format!("Failed to resolve DID: {}", e)))?; 395 - 396 - if !response.status().is_success() { 397 - return Err(LexiconFetcherError::HttpRequestFailed(format!( 398 - "Failed to resolve DID {}: HTTP {}", 399 - did, 400 - response.status() 401 - ))); 402 - } 403 - 404 - let did_doc: serde_json::Value = response 405 - .json() 406 - .await 407 - .map_err(|e| LexiconFetcherError::JsonParseFailed(format!("Failed to parse DID document: {}", e)))?; 408 - 409 - // Extract PDS endpoint from service array 410 - if let Some(services) = did_doc.get("service").and_then(|v| v.as_array()) { 411 - for service in services { 412 - if service.get("type").and_then(|v| v.as_str()) == Some("AtprotoPersonalDataServer") { 413 - if let Some(endpoint) = service.get("serviceEndpoint").and_then(|v| v.as_str()) { 414 - return Ok(endpoint.trim_end_matches('/').to_string()); 415 - } 416 - } 417 - } 418 - } 419 - 420 - return Err(LexiconFetcherError::HttpRequestFailed(format!( 421 - "No PDS endpoint found in DID document for {}", 422 - did 423 - ))); 424 - } 425 - 426 - Err(LexiconFetcherError::InvalidUrl(format!( 427 - "Unsupported DID format: {}", 428 - did 429 - ))) 430 - } 431 - } 432 - 433 - /// Mock HTTP client for testing 434 - #[derive(Clone)] 123 + /// Mock HTTP client for testing. 124 + #[derive(Clone, Default)] 435 125 pub struct MockHttpClient { 436 126 lexicons: Arc<Mutex<HashMap<String, serde_json::Value>>>, 437 127 } 438 128 439 129 impl MockHttpClient { 440 130 pub fn new() -> Self { 441 - Self { 442 - lexicons: Arc::new(Mutex::new(HashMap::new())), 443 - } 131 + Self::default() 444 132 } 445 133 446 - /// Add a mock lexicon response for a specific NSID 447 134 pub fn add_lexicon(&mut self, nsid: String, lexicon: serde_json::Value) { 448 135 self.lexicons.lock().unwrap().insert(nsid, lexicon); 449 - } 450 - } 451 - 452 - impl Default for MockHttpClient { 453 - fn default() -> Self { 454 - Self::new() 455 136 } 456 137 } 457 138 ··· 473 154 ) -> Result<Vec<(String, serde_json::Value)>> { 474 155 let lexicons = self.lexicons.lock().unwrap(); 475 156 let mut results = Vec::new(); 476 - 477 - // Handle exact match (no wildcard) 478 - if !pattern.contains('*') && !pattern.contains('_') { 479 - if let Some(lexicon) = lexicons.get(pattern) { 480 - results.push((pattern.to_string(), lexicon.clone())); 157 + for (nsid, lexicon) in lexicons.iter() { 158 + if nsid_matches_pattern(nsid, pattern) { 159 + results.push((nsid.clone(), lexicon.clone())); 481 160 } 482 - return Ok(results); 483 161 } 162 + Ok(results) 163 + } 164 + } 484 165 485 - // Handle wildcard patterns 486 - if pattern.ends_with(".*") { 487 - // "*" matches EVERYTHING 488 - // For "place.stream.*", match all: place.stream.chat, place.stream.chat.profile, etc. 489 - let base = pattern.strip_suffix(".*").unwrap(); 490 - let prefix_with_dot = format!("{}.", base); 491 - 492 - for (nsid, lexicon) in lexicons.iter() { 493 - if nsid.starts_with(&prefix_with_dot) { 494 - results.push((nsid.clone(), lexicon.clone())); 495 - } 496 - } 497 - } else if pattern.ends_with("._") { 498 - // "_" matches only DIRECT CHILDREN 499 - // For "place.stream._", match place.stream.chat but NOT place.stream.chat.profile 500 - let base = pattern.strip_suffix("._").unwrap(); 501 - let prefix_with_dot = format!("{}.", base); 502 - 503 - for (nsid, lexicon) in lexicons.iter() { 504 - if let Some(suffix) = nsid.strip_prefix(&prefix_with_dot) { 505 - // Check if it's a direct child (no more dots in the suffix) 506 - if !suffix.contains('.') && !suffix.is_empty() { 507 - results.push((nsid.clone(), lexicon.clone())); 508 - } 509 - } 510 - } 166 + /// Check whether a concrete NSID matches an optional wildcard pattern. 167 + /// 168 + /// - Exact (no wildcard): `nsid == pattern` 169 + /// - `"foo.bar.*"` matches any NSID beginning with `"foo.bar."` 170 + /// - `"foo.bar._"` matches *direct children only* of `foo.bar` — 171 + /// the suffix after `foo.bar.` must contain no further dots. 172 + fn nsid_matches_pattern(nsid: &str, pattern: &str) -> bool { 173 + if !pattern.contains('*') && !pattern.contains('_') { 174 + return nsid == pattern; 175 + } 176 + if let Some(base) = pattern.strip_suffix(".*") { 177 + let prefix = format!("{base}."); 178 + return nsid.starts_with(&prefix); 179 + } 180 + if let Some(base) = pattern.strip_suffix("._") { 181 + let prefix = format!("{base}."); 182 + if let Some(suffix) = nsid.strip_prefix(&prefix) { 183 + return !suffix.is_empty() && !suffix.contains('.'); 511 184 } 185 + } 186 + false 187 + } 512 188 513 - Ok(results) 189 + /// Extract the NSID from a fetched `com.atproto.lexicon.schema` record. 190 + /// 191 + /// Prefers the record's `id` field; falls back to the rkey in the URI. 192 + fn extract_nsid_from_record(record: &records::Record) -> Result<String> { 193 + if let Some(id) = record.value.get("id").and_then(|v| v.as_str()) { 194 + return Ok(id.to_string()); 514 195 } 196 + if let Some(rkey) = record.uri.split('/').next_back() { 197 + return Ok(rkey.to_string()); 198 + } 199 + Err(LexiconFetcherError::MalformedRecord(record.uri.clone())) 515 200 } 516 201 517 - /// Main lexicon fetcher that combines DNS resolution and HTTP fetching 202 + // --------------------------------------------------------------------------- 203 + // Fetcher combining DNS + HTTP 204 + // --------------------------------------------------------------------------- 205 + 518 206 pub struct LexiconFetcher<D: DnsResolver, H: HttpClient> { 519 207 dns_resolver: D, 520 208 http_client: H, ··· 528 216 } 529 217 } 530 218 531 - /// Fetch a single lexicon by NSID 532 - /// Example: "place.stream.chat.profile" -> single lexicon JSON 219 + /// Fetch a single lexicon by exact NSID. 533 220 pub async fn fetch(&self, nsid: &str) -> Result<serde_json::Value> { 534 - // Check if this is a wildcard pattern 535 221 if nsid.contains('*') || nsid.contains('_') { 536 222 return Err(LexiconFetcherError::InvalidNsid(format!( 537 - "Use fetch_pattern() for wildcard patterns (* or _): {}", 538 - nsid 223 + "Use fetch_pattern() for wildcard patterns (* or _): {nsid}" 539 224 ))); 540 225 } 541 - 542 - // Parse NSID into authority and name segments 543 226 let (authority, name_segments) = parse_nsid(nsid)?; 544 - 545 - // Resolve DID via DNS (async) 546 - let did = self.dns_resolver.resolve_lexicon_did(&authority, &name_segments).await?; 547 - 548 - // Fetch lexicon via HTTP 227 + let did = self 228 + .dns_resolver 229 + .resolve_lexicon_did(&authority, &name_segments) 230 + .await?; 549 231 self.http_client.fetch_lexicon(&did, nsid).await 550 232 } 551 233 552 - /// Fetch all lexicons matching a pattern 553 - /// Examples: 554 - /// - "place.stream.*" -> matches everything (place.stream.chat, place.stream.chat.profile, etc.) 555 - /// - "place.stream._" -> matches direct children only (place.stream.chat, place.stream.key, but not place.stream.chat.profile) 234 + /// Fetch all lexicons matching a wildcard pattern (`…*` or `…_`). 556 235 pub async fn fetch_pattern(&self, pattern: &str) -> Result<Vec<(String, serde_json::Value)>> { 557 - // Parse pattern to extract authority 558 236 let (authority, name_pattern) = parse_nsid(pattern)?; 559 - 560 - // For DNS lookup, remove wildcard suffix (both .* and ._) 561 - // For "place.stream.*" or "place.stream._", name_pattern is "*" or "_", so we use empty string 562 - // For "place.stream.chat.*", name_pattern is "chat.*", so we use "chat" 563 - let dns_name_segments = if name_pattern == "*" || name_pattern == "_" { 564 - "" 565 - } else if let Some(pos) = name_pattern.rfind(".*") { 566 - &name_pattern[..pos] 567 - } else if let Some(pos) = name_pattern.rfind("._") { 568 - &name_pattern[..pos] 569 - } else { 570 - &name_pattern 571 - }; 572 - 573 - // Resolve DID via DNS (async) 574 - let did = self.dns_resolver.resolve_lexicon_did(&authority, dns_name_segments).await?; 237 + let dns_name_segments = strip_wildcard(&name_pattern); 238 + let did = self 239 + .dns_resolver 240 + .resolve_lexicon_did(&authority, dns_name_segments) 241 + .await?; 242 + self.http_client.fetch_lexicons_pattern(&did, pattern).await 243 + } 244 + } 575 245 576 - // Fetch lexicons matching pattern via HTTP 577 - self.http_client.fetch_lexicons_pattern(&did, pattern).await 246 + /// For a name-pattern like `chat.*` or `chat._`, return `"chat"`. For 247 + /// the bare `*` / `_`, return `""`. Otherwise return the pattern itself. 248 + fn strip_wildcard(name_pattern: &str) -> &str { 249 + if name_pattern == "*" || name_pattern == "_" { 250 + return ""; 251 + } 252 + if let Some(pos) = name_pattern.rfind(".*") { 253 + return &name_pattern[..pos]; 254 + } 255 + if let Some(pos) = name_pattern.rfind("._") { 256 + return &name_pattern[..pos]; 578 257 } 258 + name_pattern 579 259 } 580 260 581 - /// Metadata about a fetched lexicon 261 + /// Metadata about a fetched lexicon. 582 262 #[derive(Debug, Clone)] 583 263 pub struct FetchedLexicon { 584 264 pub nsid: String, ··· 586 266 pub did: String, 587 267 } 588 268 589 - /// Result of fetching one or more lexicons 269 + /// Result of fetching one or more lexicons. 590 270 #[derive(Debug)] 591 271 pub struct FetchResult { 592 272 pub lexicons: Vec<FetchedLexicon>, 593 273 } 594 274 595 - /// Convenience type for production use with real DNS and HTTP 596 275 pub type ProductionLexiconFetcher = LexiconFetcher<RealDnsResolver, RealHttpClient>; 597 276 598 277 impl ProductionLexiconFetcher { 599 - /// Create a new production fetcher with default configuration 278 + /// Create a fetcher with default DNS + HTTP configuration. 600 279 pub async fn production() -> Result<Self> { 601 - Ok(Self::new(RealDnsResolver::new().await?, RealHttpClient::new())) 280 + Ok(Self::new(RealDnsResolver::new()?, RealHttpClient::new())) 602 281 } 603 282 } 604 283 605 284 impl<D: DnsResolver, H: HttpClient> LexiconFetcher<D, H> { 606 - /// Fetch one or more lexicons and return metadata for lockfile tracking 607 - /// Handles both exact NSIDs and patterns (* or _) 285 + /// Fetch with metadata (NSID + DID) for lockfile tracking. 286 + /// Handles both exact NSIDs and wildcard patterns. 608 287 pub async fn fetch_with_metadata(&self, nsid: &str) -> Result<FetchResult> { 609 - // Parse pattern to extract authority and name segments 610 288 let (authority, name_segments) = parse_nsid(nsid)?; 289 + let dns_name_segments = strip_wildcard(&name_segments); 290 + let did = self 291 + .dns_resolver 292 + .resolve_lexicon_did(&authority, dns_name_segments) 293 + .await?; 611 294 612 - // For DNS lookup, remove wildcard suffix (both .* and ._) 613 - let dns_name_segments = if name_segments == "*" || name_segments == "_" { 614 - "" 615 - } else if let Some(pos) = name_segments.rfind(".*") { 616 - &name_segments[..pos] 617 - } else if let Some(pos) = name_segments.rfind("._") { 618 - &name_segments[..pos] 619 - } else { 620 - &name_segments 621 - }; 622 - 623 - // Resolve DID via DNS (async) 624 - let did = self.dns_resolver.resolve_lexicon_did(&authority, dns_name_segments).await?; 625 - 626 - // Fetch lexicons 627 295 let lexicons = if nsid.contains('*') || nsid.contains('_') { 628 296 self.http_client.fetch_lexicons_pattern(&did, nsid).await? 629 297 } else { ··· 631 299 vec![(nsid.to_string(), lexicon)] 632 300 }; 633 301 634 - // Package results with metadata 635 302 let fetched_lexicons = lexicons 636 303 .into_iter() 637 304 .map(|(nsid, lexicon)| FetchedLexicon { ··· 646 313 }) 647 314 } 648 315 649 - /// Fetch multiple NSIDs in sequence (no optimization) 316 + /// Fetch multiple NSIDs sequentially. 650 317 pub async fn fetch_many(&self, nsids: &[String]) -> Result<Vec<FetchResult>> { 651 318 let mut results = Vec::new(); 652 319 for nsid in nsids { ··· 655 322 Ok(results) 656 323 } 657 324 658 - /// Fetch multiple NSIDs with optimization to reduce network requests 659 - /// Groups similar NSIDs into wildcard patterns when beneficial 660 - /// Example: ["app.bsky.actor.foo", "app.bsky.actor.bar"] -> fetches "app.bsky.actor.*" 325 + /// Fetch multiple NSIDs, collapsing them into wildcard patterns where 326 + /// beneficial to reduce network round-trips. 661 327 pub async fn fetch_many_optimized(&self, nsids: &[String]) -> Result<Vec<FetchResult>> { 662 328 use std::collections::HashSet; 663 - 664 329 if nsids.is_empty() { 665 330 return Ok(Vec::new()); 666 331 } 667 - 668 - // Convert to HashSet for optimization 669 332 let nsids_set: HashSet<String> = nsids.iter().cloned().collect(); 670 - 671 - // Optimize into minimal set of patterns 672 333 let optimized_patterns = optimize_fetch_patterns(&nsids_set); 673 - 674 - // Fetch each optimized pattern 675 334 let mut results = Vec::new(); 676 335 for pattern in optimized_patterns { 677 336 results.push(self.fetch_with_metadata(&pattern).await?); 678 337 } 679 - 680 338 Ok(results) 681 339 } 682 340 683 - /// Fetch lexicon(s) from a known DID, bypassing DNS resolution 684 - /// Useful when fetching from lockfile where DID is already known 685 - /// Handles both exact NSIDs and patterns (* or _) 341 + /// Fetch from a known DID, skipping DNS resolution (for lockfile replay). 686 342 pub async fn fetch_from_did_with_metadata(&self, did: &str, nsid: &str) -> Result<FetchResult> { 687 - // Fetch lexicons directly from the DID 688 343 let lexicons = if nsid.contains('*') || nsid.contains('_') { 689 344 self.http_client.fetch_lexicons_pattern(did, nsid).await? 690 345 } else { 691 346 let lexicon = self.http_client.fetch_lexicon(did, nsid).await?; 692 347 vec![(nsid.to_string(), lexicon)] 693 348 }; 694 - 695 - // Package results with metadata 696 349 let fetched_lexicons = lexicons 697 350 .into_iter() 698 351 .map(|(nsid, lexicon)| FetchedLexicon { ··· 701 354 did: did.to_string(), 702 355 }) 703 356 .collect(); 704 - 705 357 Ok(FetchResult { 706 358 lexicons: fetched_lexicons, 707 359 }) 708 360 } 709 361 } 710 362 711 - /// Convenience type for testing with mocks 712 363 pub type MockLexiconFetcher = LexiconFetcher<MockDnsResolver, MockHttpClient>; 713 364 714 365 impl MockLexiconFetcher { 715 - /// Create a new mock fetcher for testing 716 366 pub fn mock() -> Self { 717 367 Self::new(MockDnsResolver::new(), MockHttpClient::new()) 718 368 } 719 369 } 720 370 721 - /// Extract NSID from an ATProto record 722 - fn extract_nsid_from_record(record: &AtProtoRecord) -> Result<String> { 723 - // The record value should have an "id" field with the NSID 724 - if let Some(id) = record.value.get("id").and_then(|v| v.as_str()) { 725 - return Ok(id.to_string()); 726 - } 727 - 728 - // Fallback: try to extract from URI 729 - // URI format: at://did:plc:xxx/com.atproto.lexicon.schema/nsid 730 - if let Some(rkey) = record.uri.split('/').last() { 731 - return Ok(rkey.to_string()); 732 - } 733 - 734 - Err(LexiconFetcherError::HttpRequestFailed(format!( 735 - "Could not extract NSID from record: {}", 736 - record.uri 737 - ))) 738 - } 739 - 740 - /// Optimize a set of NSIDs by collapsing them into the minimal set of fetch patterns 741 - /// For example: ["app.bsky.actor.foo", "app.bsky.actor.bar"] -> ["app.bsky.actor.*"] 742 - /// This function tries multiple grouping strategies to find the most efficient pattern 371 + /// Collapse a set of NSIDs into the minimum number of fetch patterns. 372 + /// 373 + /// - Two or more NSIDs that share a complete prefix (all but the final 374 + /// segment) become `<prefix>.*`. 375 + /// - Three or more NSIDs under the same authority that weren't otherwise 376 + /// grouped become `<authority>.*`. 377 + /// - Anything left over is emitted exact. 743 378 pub fn optimize_fetch_patterns(nsids: &std::collections::HashSet<String>) -> Vec<String> { 744 379 use std::collections::{BTreeMap, HashSet}; 745 - 746 380 if nsids.is_empty() { 747 381 return Vec::new(); 748 382 } 749 383 750 - // Strategy 1: Try grouping by authority (first 2 segments) 751 - // e.g., ["app.bsky.actor.foo", "app.bsky.feed.bar"] -> ["app.bsky.*"] 752 384 let mut authority_groups: BTreeMap<String, Vec<String>> = BTreeMap::new(); 753 - 385 + let mut prefix_groups: BTreeMap<String, Vec<String>> = BTreeMap::new(); 754 386 for nsid in nsids { 755 387 let parts: Vec<&str> = nsid.split('.').collect(); 756 388 if parts.len() >= 2 { 757 389 let authority = format!("{}.{}", parts[0], parts[1]); 758 - authority_groups.entry(authority).or_insert_with(Vec::new).push(nsid.clone()); 390 + authority_groups 391 + .entry(authority) 392 + .or_default() 393 + .push(nsid.clone()); 759 394 } 760 - } 761 - 762 - // Strategy 2: Try grouping by namespace prefix (all but last segment) 763 - // e.g., ["app.bsky.actor.foo", "app.bsky.actor.bar"] -> ["app.bsky.actor.*"] 764 - let mut prefix_groups: BTreeMap<String, Vec<String>> = BTreeMap::new(); 765 - 766 - for nsid in nsids { 767 - let parts: Vec<&str> = nsid.split('.').collect(); 768 395 if parts.len() >= 3 { 769 396 let prefix = parts[..parts.len() - 1].join("."); 770 - prefix_groups.entry(prefix).or_insert_with(Vec::new).push(nsid.clone()); 397 + prefix_groups.entry(prefix).or_default().push(nsid.clone()); 771 398 } 772 399 } 773 400 774 401 let mut result = Vec::new(); 775 - let mut handled_nsids = HashSet::new(); 402 + let mut handled: HashSet<String> = HashSet::new(); 776 403 777 - // First pass: Apply namespace-level grouping (more specific) 404 + // Specific-prefix wildcards first (stricter match, fewer false positives). 778 405 for (prefix, group) in &prefix_groups { 779 - if group.len() >= 2 && !handled_nsids.contains(&group[0]) { 780 - result.push(format!("{}.*", prefix)); 406 + if group.len() >= 2 && !handled.contains(&group[0]) { 407 + result.push(format!("{prefix}.*")); 781 408 for nsid in group { 782 - handled_nsids.insert(nsid.clone()); 409 + handled.insert(nsid.clone()); 783 410 } 784 411 } 785 412 } 786 413 787 - // Second pass: For remaining NSIDs, consider authority-level grouping 788 - // Only use authority wildcard if we have 3+ different namespaces under same authority 414 + // Authority-level wildcards only if we'd save 3+ calls. 789 415 for (authority, group) in &authority_groups { 790 - let unhandled: Vec<&String> = group.iter() 791 - .filter(|nsid| !handled_nsids.contains(*nsid)) 792 - .collect(); 793 - 416 + let unhandled: Vec<&String> = group.iter().filter(|n| !handled.contains(*n)).collect(); 794 417 if unhandled.len() >= 3 { 795 - result.push(format!("{}.*", authority)); 796 - for nsid in &unhandled { 797 - handled_nsids.insert((*nsid).clone()); 418 + result.push(format!("{authority}.*")); 419 + for nsid in unhandled { 420 + handled.insert(nsid.clone()); 798 421 } 799 422 } 800 423 } 801 424 802 - // Third pass: Add remaining individual NSIDs 425 + // Emit remaining NSIDs exact. 803 426 for nsid in nsids { 804 - if !handled_nsids.contains(nsid) { 427 + if !handled.contains(nsid) { 805 428 result.push(nsid.clone()); 806 429 } 807 430 } 808 431 809 - // Sort for consistent output 810 432 result.sort(); 811 433 result 812 434 } ··· 816 438 use super::*; 817 439 818 440 #[test] 819 - fn test_construct_dns_name() { 820 - assert_eq!( 821 - construct_dns_name("place.stream", "key"), 822 - "_lexicon.key.stream.place" 823 - ); 824 - assert_eq!( 825 - construct_dns_name("app.bsky", "actor"), 826 - "_lexicon.actor.bsky.app" 827 - ); 828 - assert_eq!( 829 - construct_dns_name("app.bsky", "actor.profile"), 830 - "_lexicon.actor.profile.bsky.app" 831 - ); 832 - assert_eq!( 833 - construct_dns_name("place.stream", ""), 834 - "_lexicon.stream.place" 835 - ); 441 + fn nsid_matches_pattern_exact() { 442 + assert!(nsid_matches_pattern("foo.bar.baz", "foo.bar.baz")); 443 + assert!(!nsid_matches_pattern("foo.bar.baz", "foo.bar.qux")); 836 444 } 837 445 838 446 #[test] 839 - fn test_parse_nsid() { 840 - let (auth, name) = parse_nsid("place.stream.key").unwrap(); 841 - assert_eq!(auth, "place.stream"); 842 - assert_eq!(name, "key"); 843 - 844 - let (auth, name) = parse_nsid("app.bsky.actor.profile").unwrap(); 845 - assert_eq!(auth, "app.bsky"); 846 - assert_eq!(name, "actor.profile"); 847 - 848 - let (auth, name) = parse_nsid("place.stream").unwrap(); 849 - assert_eq!(auth, "place.stream"); 850 - assert_eq!(name, ""); 851 - 852 - assert!(parse_nsid("invalid").is_err()); 447 + fn nsid_matches_pattern_wildcard_star() { 448 + assert!(nsid_matches_pattern("foo.bar.baz", "foo.bar.*")); 449 + assert!(nsid_matches_pattern("foo.bar.baz.deep", "foo.bar.*")); 450 + assert!(!nsid_matches_pattern("foo.qux.baz", "foo.bar.*")); 853 451 } 854 452 855 - #[tokio::test] 856 - async fn test_mock_dns_resolver() { 857 - let mut resolver = MockDnsResolver::new(); 858 - resolver.add_record("place.stream", "key", "did:plc:test123".to_string()); 859 - 860 - let did = resolver.resolve_lexicon_did("place.stream", "key").await.unwrap(); 861 - assert_eq!(did, "did:plc:test123"); 862 - 863 - let result = resolver.resolve_lexicon_did("place.stream", "notfound").await; 864 - assert!(result.is_err()); 453 + #[test] 454 + fn nsid_matches_pattern_direct_child_only() { 455 + assert!(nsid_matches_pattern("foo.bar.baz", "foo.bar._")); 456 + assert!(!nsid_matches_pattern("foo.bar.baz.deep", "foo.bar._")); 865 457 } 866 458 867 - #[tokio::test] 868 - async fn test_mock_dns_resolver_from_nsid() { 869 - let mut resolver = MockDnsResolver::new(); 870 - resolver 871 - .add_record_from_nsid("app.bsky.actor.profile", "did:plc:bsky123".to_string()) 872 - .unwrap(); 873 - 874 - let did = resolver 875 - .resolve_lexicon_did("app.bsky", "actor.profile") 876 - .await 877 - .unwrap(); 878 - assert_eq!(did, "did:plc:bsky123"); 459 + #[test] 460 + fn strip_wildcard_handles_all_forms() { 461 + assert_eq!(strip_wildcard("*"), ""); 462 + assert_eq!(strip_wildcard("_"), ""); 463 + assert_eq!(strip_wildcard("chat.*"), "chat"); 464 + assert_eq!(strip_wildcard("chat._"), "chat"); 465 + assert_eq!(strip_wildcard("chat"), "chat"); 879 466 } 880 467 }
+103 -26
mlf-lexicon-fetcher/tests/dns_scenarios.rs
··· 15 15 #[tokio::test] 16 16 async fn test_dns_record_not_found() { 17 17 let resolver = MockDnsResolver::new(); 18 - let result = resolver.resolve_lexicon_did("nonexistent.domain", "test").await; 18 + let result = resolver 19 + .resolve_lexicon_did("nonexistent.domain", "test") 20 + .await; 19 21 assert!(result.is_err()); 20 - assert!(result.unwrap_err().to_string().contains("No mock record found")); 22 + assert!( 23 + result 24 + .unwrap_err() 25 + .to_string() 26 + .contains("No mock record found") 27 + ); 21 28 } 22 29 23 30 #[tokio::test] ··· 34 41 resolver.add_record("com.atproto", "repo", "did:plc:atproto001".to_string()); 35 42 36 43 assert_eq!( 37 - resolver.resolve_lexicon_did("app.bsky", "actor").await.unwrap(), 44 + resolver 45 + .resolve_lexicon_did("app.bsky", "actor") 46 + .await 47 + .unwrap(), 38 48 "did:plc:bsky001" 39 49 ); 40 50 assert_eq!( 41 - resolver.resolve_lexicon_did("place.stream", "chat").await.unwrap(), 51 + resolver 52 + .resolve_lexicon_did("place.stream", "chat") 53 + .await 54 + .unwrap(), 42 55 "did:plc:stream001" 43 56 ); 44 57 assert_eq!( 45 - resolver.resolve_lexicon_did("com.atproto", "repo").await.unwrap(), 58 + resolver 59 + .resolve_lexicon_did("com.atproto", "repo") 60 + .await 61 + .unwrap(), 46 62 "did:plc:atproto001" 47 63 ); 48 64 } ··· 58 74 resolver.add_record("app.bsky", "actor.profile", "did:plc:double".to_string()); 59 75 60 76 // Three segments 61 - resolver.add_record("app.bsky", "actor.profile.detailed", "did:plc:triple".to_string()); 77 + resolver.add_record( 78 + "app.bsky", 79 + "actor.profile.detailed", 80 + "did:plc:triple".to_string(), 81 + ); 62 82 63 83 assert_eq!( 64 - resolver.resolve_lexicon_did("app.bsky", "actor").await.unwrap(), 84 + resolver 85 + .resolve_lexicon_did("app.bsky", "actor") 86 + .await 87 + .unwrap(), 65 88 "did:plc:single" 66 89 ); 67 90 assert_eq!( 68 - resolver.resolve_lexicon_did("app.bsky", "actor.profile").await.unwrap(), 91 + resolver 92 + .resolve_lexicon_did("app.bsky", "actor.profile") 93 + .await 94 + .unwrap(), 69 95 "did:plc:double" 70 96 ); 71 97 assert_eq!( 72 - resolver.resolve_lexicon_did("app.bsky", "actor.profile.detailed").await.unwrap(), 98 + resolver 99 + .resolve_lexicon_did("app.bsky", "actor.profile.detailed") 100 + .await 101 + .unwrap(), 73 102 "did:plc:triple" 74 103 ); 75 104 } ··· 82 111 resolver.add_record("place.stream", "", "did:plc:root".to_string()); 83 112 84 113 assert_eq!( 85 - resolver.resolve_lexicon_did("place.stream", "").await.unwrap(), 114 + resolver 115 + .resolve_lexicon_did("place.stream", "") 116 + .await 117 + .unwrap(), 86 118 "did:plc:root" 87 119 ); 88 120 } ··· 94 126 resolver.add_record("example.com", "api", "did:web:example.com".to_string()); 95 127 96 128 assert_eq!( 97 - resolver.resolve_lexicon_did("example.com", "api").await.unwrap(), 129 + resolver 130 + .resolve_lexicon_did("example.com", "api") 131 + .await 132 + .unwrap(), 98 133 "did:web:example.com" 99 134 ); 100 135 } ··· 107 142 resolver.add_record( 108 143 "app.bsky", 109 144 "feed", 110 - "did:plc:z72i7hdynmk6r22z27h6tvur".to_string() 145 + "did:plc:z72i7hdynmk6r22z27h6tvur".to_string(), 111 146 ); 112 147 113 - let did = resolver.resolve_lexicon_did("app.bsky", "feed").await.unwrap(); 148 + let did = resolver 149 + .resolve_lexicon_did("app.bsky", "feed") 150 + .await 151 + .unwrap(); 114 152 assert!(did.starts_with("did:plc:")); 115 153 assert_eq!(did.len(), 32); // "did:plc:" (8) + 24 chars 116 154 } ··· 120 158 let mut resolver = MockDnsResolver::new(); 121 159 122 160 // Add using full NSID 123 - resolver.add_record_from_nsid("place.stream.key", "did:plc:test".to_string()).unwrap(); 124 - resolver.add_record_from_nsid("app.bsky.actor.profile", "did:plc:bsky".to_string()).unwrap(); 161 + resolver 162 + .add_record_from_nsid("place.stream.key", "did:plc:test".to_string()) 163 + .unwrap(); 164 + resolver 165 + .add_record_from_nsid("app.bsky.actor.profile", "did:plc:bsky".to_string()) 166 + .unwrap(); 125 167 126 168 assert_eq!( 127 - resolver.resolve_lexicon_did("place.stream", "key").await.unwrap(), 169 + resolver 170 + .resolve_lexicon_did("place.stream", "key") 171 + .await 172 + .unwrap(), 128 173 "did:plc:test" 129 174 ); 130 175 assert_eq!( 131 - resolver.resolve_lexicon_did("app.bsky", "actor.profile").await.unwrap(), 176 + resolver 177 + .resolve_lexicon_did("app.bsky", "actor.profile") 178 + .await 179 + .unwrap(), 132 180 "did:plc:bsky" 133 181 ); 134 182 } ··· 140 188 // NSID with only one segment 141 189 let result = resolver.add_record_from_nsid("invalid", "did:plc:test".to_string()); 142 190 assert!(result.is_err()); 143 - assert!(result.unwrap_err().to_string().contains("at least 2 segments")); 191 + assert!( 192 + result 193 + .unwrap_err() 194 + .to_string() 195 + .contains("at least 2 segments") 196 + ); 144 197 } 145 198 146 199 #[tokio::test] ··· 153 206 154 207 // These should be treated as different domains 155 208 assert_eq!( 156 - resolver.resolve_lexicon_did("App.Bsky", "Actor").await.unwrap(), 209 + resolver 210 + .resolve_lexicon_did("App.Bsky", "Actor") 211 + .await 212 + .unwrap(), 157 213 "did:plc:uppercase" 158 214 ); 159 215 assert_eq!( 160 - resolver.resolve_lexicon_did("app.bsky", "actor").await.unwrap(), 216 + resolver 217 + .resolve_lexicon_did("app.bsky", "actor") 218 + .await 219 + .unwrap(), 161 220 "did:plc:lowercase" 162 221 ); 163 222 } ··· 176 235 for _ in 0..10 { 177 236 let resolver_clone = Arc::clone(&resolver); 178 237 let handle = tokio::spawn(async move { 179 - resolver_clone.resolve_lexicon_did("app.bsky", "feed").await.unwrap() 238 + resolver_clone 239 + .resolve_lexicon_did("app.bsky", "feed") 240 + .await 241 + .unwrap() 180 242 }); 181 243 handles.push(handle); 182 244 } ··· 199 261 200 262 // All should resolve to the same DID 201 263 assert_eq!( 202 - resolver.resolve_lexicon_did("app.bsky", "actor.defs").await.unwrap(), 264 + resolver 265 + .resolve_lexicon_did("app.bsky", "actor.defs") 266 + .await 267 + .unwrap(), 203 268 "did:plc:bsky" 204 269 ); 205 270 assert_eq!( 206 - resolver.resolve_lexicon_did("app.bsky", "feed.post").await.unwrap(), 271 + resolver 272 + .resolve_lexicon_did("app.bsky", "feed.post") 273 + .await 274 + .unwrap(), 207 275 "did:plc:bsky" 208 276 ); 209 277 } ··· 253 321 resolver.add_record("test.com", "api", "".to_string()); 254 322 255 323 assert_eq!( 256 - resolver.resolve_lexicon_did("test.com", "api").await.unwrap(), 324 + resolver 325 + .resolve_lexicon_did("test.com", "api") 326 + .await 327 + .unwrap(), 257 328 "" 258 329 ); 259 330 } ··· 267 338 resolver.add_record("app.bsky", long_name, "did:plc:deep".to_string()); 268 339 269 340 assert_eq!( 270 - resolver.resolve_lexicon_did("app.bsky", long_name).await.unwrap(), 341 + resolver 342 + .resolve_lexicon_did("app.bsky", long_name) 343 + .await 344 + .unwrap(), 271 345 "did:plc:deep" 272 346 ); 273 347 } ··· 280 354 resolver.add_record("app-test.bsky-123", "actor", "did:plc:special".to_string()); 281 355 282 356 assert_eq!( 283 - resolver.resolve_lexicon_did("app-test.bsky-123", "actor").await.unwrap(), 357 + resolver 358 + .resolve_lexicon_did("app-test.bsky-123", "actor") 359 + .await 360 + .unwrap(), 284 361 "did:plc:special" 285 362 ); 286 363 }
+99 -34
mlf-lexicon-fetcher/tests/lexicon_fetching.rs
··· 1 1 // Integration tests for full lexicon fetching flow (DNS + HTTP) 2 2 3 - use mlf_lexicon_fetcher::{ 4 - LexiconFetcher, MockDnsResolver, MockHttpClient, LexiconFetcherError, 5 - }; 3 + use mlf_lexicon_fetcher::{LexiconFetcher, LexiconFetcherError, MockDnsResolver, MockHttpClient}; 6 4 use serde_json::json; 7 5 8 6 #[tokio::test] 9 7 async fn test_fetch_single_lexicon() { 10 8 // Setup mock DNS resolver 11 9 let mut dns_resolver = MockDnsResolver::new(); 12 - dns_resolver.add_record("place.stream", "chat.profile", "did:plc:test123".to_string()); 10 + dns_resolver.add_record( 11 + "place.stream", 12 + "chat.profile", 13 + "did:plc:test123".to_string(), 14 + ); 13 15 14 16 // Setup mock HTTP client 15 17 let mut http_client = MockHttpClient::new(); ··· 23 25 } 24 26 } 25 27 }); 26 - http_client.add_lexicon("place.stream.chat.profile".to_string(), lexicon_json.clone()); 28 + http_client.add_lexicon( 29 + "place.stream.chat.profile".to_string(), 30 + lexicon_json.clone(), 31 + ); 27 32 28 33 // Create fetcher 29 34 let fetcher = LexiconFetcher::new(dns_resolver, http_client); ··· 32 37 let result = fetcher.fetch("place.stream.chat.profile").await; 33 38 assert!(result.is_ok()); 34 39 let fetched = result.unwrap(); 35 - assert_eq!(fetched.get("id").unwrap().as_str().unwrap(), "place.stream.chat.profile"); 40 + assert_eq!( 41 + fetched.get("id").unwrap().as_str().unwrap(), 42 + "place.stream.chat.profile" 43 + ); 36 44 } 37 45 38 46 #[tokio::test] ··· 86 94 async fn test_fetch_lexicon_not_found() { 87 95 // Setup mock DNS resolver 88 96 let mut dns_resolver = MockDnsResolver::new(); 89 - dns_resolver.add_record("place.stream", "chat.profile", "did:plc:test123".to_string()); 97 + dns_resolver.add_record( 98 + "place.stream", 99 + "chat.profile", 100 + "did:plc:test123".to_string(), 101 + ); 90 102 91 103 // Setup mock HTTP client (but don't add the lexicon) 92 104 let http_client = MockHttpClient::new(); ··· 122 134 assert!(result.is_err()); 123 135 124 136 match result { 125 - Err(LexiconFetcherError::LookupFailed { domain, .. }) => { 137 + Err(LexiconFetcherError::Identity( 138 + mlf_atproto::identity::IdentityError::DnsLookupFailed { domain, .. }, 139 + )) => { 126 140 assert_eq!(domain, "_lexicon.chat.profile.stream.place"); 127 141 } 128 - _ => panic!("Expected LookupFailed error"), 142 + other => panic!("Expected DnsLookupFailed, got {other:?}"), 129 143 } 130 144 } 131 145 ··· 171 185 async fn test_multiple_authorities() { 172 186 // Setup mock DNS resolver with multiple authorities 173 187 let mut dns_resolver = MockDnsResolver::new(); 174 - dns_resolver.add_record("place.stream", "chat.profile", "did:plc:stream123".to_string()); 188 + dns_resolver.add_record( 189 + "place.stream", 190 + "chat.profile", 191 + "did:plc:stream123".to_string(), 192 + ); 175 193 dns_resolver.add_record("app.bsky", "actor.profile", "did:plc:bsky456".to_string()); 176 194 177 195 // Setup mock HTTP client ··· 217 235 let fetcher = LexiconFetcher::new(dns_resolver, http_client); 218 236 219 237 // Fetch deeply nested lexicon 220 - let result = fetcher.fetch("place.stream.chat.message.attachments.image").await; 238 + let result = fetcher 239 + .fetch("place.stream.chat.message.attachments.image") 240 + .await; 221 241 assert!(result.is_ok()); 222 242 } 223 243 ··· 228 248 229 249 // Setup mock DNS resolver 230 250 let mut dns_resolver = MockDnsResolver::new(); 231 - dns_resolver.add_record("place.stream", "chat.profile", "did:plc:test123".to_string()); 251 + dns_resolver.add_record( 252 + "place.stream", 253 + "chat.profile", 254 + "did:plc:test123".to_string(), 255 + ); 232 256 233 257 // Setup mock HTTP client 234 258 let mut http_client = MockHttpClient::new(); ··· 244 268 let mut handles = vec![]; 245 269 for _ in 0..10 { 246 270 let fetcher_clone = Arc::clone(&fetcher); 247 - let handle = task::spawn(async move { 248 - fetcher_clone.fetch("place.stream.chat.profile").await 249 - }); 271 + let handle = 272 + task::spawn(async move { fetcher_clone.fetch("place.stream.chat.profile").await }); 250 273 handles.push(handle); 251 274 } 252 275 ··· 265 288 266 289 // Setup mock HTTP client 267 290 let mut http_client = MockHttpClient::new(); 268 - http_client.add_lexicon("app.bsky.feed.post".to_string(), json!({"id": "app.bsky.feed.post"})); 269 - http_client.add_lexicon("app.bsky.feed.like".to_string(), json!({"id": "app.bsky.feed.like"})); 270 - http_client.add_lexicon("app.bsky.feed.repost".to_string(), json!({"id": "app.bsky.feed.repost"})); 271 - http_client.add_lexicon("app.bsky.actor.profile".to_string(), json!({"id": "app.bsky.actor.profile"})); 291 + http_client.add_lexicon( 292 + "app.bsky.feed.post".to_string(), 293 + json!({"id": "app.bsky.feed.post"}), 294 + ); 295 + http_client.add_lexicon( 296 + "app.bsky.feed.like".to_string(), 297 + json!({"id": "app.bsky.feed.like"}), 298 + ); 299 + http_client.add_lexicon( 300 + "app.bsky.feed.repost".to_string(), 301 + json!({"id": "app.bsky.feed.repost"}), 302 + ); 303 + http_client.add_lexicon( 304 + "app.bsky.actor.profile".to_string(), 305 + json!({"id": "app.bsky.actor.profile"}), 306 + ); 272 307 273 308 // Create fetcher 274 309 let fetcher = LexiconFetcher::new(dns_resolver, http_client); ··· 293 328 294 329 // Setup mock HTTP client with nested lexicons 295 330 let mut http_client = MockHttpClient::new(); 296 - 331 + 297 332 // Direct children of place.stream 298 - http_client.add_lexicon("place.stream.chat".to_string(), json!({"id": "place.stream.chat"})); 299 - http_client.add_lexicon("place.stream.key".to_string(), json!({"id": "place.stream.key"})); 300 - http_client.add_lexicon("place.stream.livestream".to_string(), json!({"id": "place.stream.livestream"})); 301 - 333 + http_client.add_lexicon( 334 + "place.stream.chat".to_string(), 335 + json!({"id": "place.stream.chat"}), 336 + ); 337 + http_client.add_lexicon( 338 + "place.stream.key".to_string(), 339 + json!({"id": "place.stream.key"}), 340 + ); 341 + http_client.add_lexicon( 342 + "place.stream.livestream".to_string(), 343 + json!({"id": "place.stream.livestream"}), 344 + ); 345 + 302 346 // Nested children (should NOT match with _) 303 - http_client.add_lexicon("place.stream.chat.profile".to_string(), json!({"id": "place.stream.chat.profile"})); 304 - http_client.add_lexicon("place.stream.chat.message".to_string(), json!({"id": "place.stream.chat.message"})); 305 - http_client.add_lexicon("place.stream.key.defs".to_string(), json!({"id": "place.stream.key.defs"})); 347 + http_client.add_lexicon( 348 + "place.stream.chat.profile".to_string(), 349 + json!({"id": "place.stream.chat.profile"}), 350 + ); 351 + http_client.add_lexicon( 352 + "place.stream.chat.message".to_string(), 353 + json!({"id": "place.stream.chat.message"}), 354 + ); 355 + http_client.add_lexicon( 356 + "place.stream.key.defs".to_string(), 357 + json!({"id": "place.stream.key.defs"}), 358 + ); 306 359 307 360 // Create fetcher 308 361 let fetcher = LexiconFetcher::new(dns_resolver, http_client); ··· 314 367 315 368 // Should only get 3 direct children 316 369 assert_eq!(lexicons.len(), 3); 317 - 370 + 318 371 let nsids: Vec<&str> = lexicons.iter().map(|(nsid, _)| nsid.as_str()).collect(); 319 372 assert!(nsids.contains(&"place.stream.chat")); 320 373 assert!(nsids.contains(&"place.stream.key")); 321 374 assert!(nsids.contains(&"place.stream.livestream")); 322 - 375 + 323 376 // Should NOT contain nested children 324 377 assert!(!nsids.contains(&"place.stream.chat.profile")); 325 378 assert!(!nsids.contains(&"place.stream.chat.message")); ··· 334 387 335 388 // Setup mock HTTP client with nested lexicons 336 389 let mut http_client = MockHttpClient::new(); 337 - http_client.add_lexicon("place.stream.chat".to_string(), json!({"id": "place.stream.chat"})); 338 - http_client.add_lexicon("place.stream.chat.profile".to_string(), json!({"id": "place.stream.chat.profile"})); 339 - http_client.add_lexicon("place.stream.key".to_string(), json!({"id": "place.stream.key"})); 390 + http_client.add_lexicon( 391 + "place.stream.chat".to_string(), 392 + json!({"id": "place.stream.chat"}), 393 + ); 394 + http_client.add_lexicon( 395 + "place.stream.chat.profile".to_string(), 396 + json!({"id": "place.stream.chat.profile"}), 397 + ); 398 + http_client.add_lexicon( 399 + "place.stream.key".to_string(), 400 + json!({"id": "place.stream.key"}), 401 + ); 340 402 341 403 // Create fetcher 342 404 let fetcher = LexiconFetcher::new(dns_resolver, http_client); ··· 352 414 assert!(underscore_result.is_ok()); 353 415 let underscore_lexicons = underscore_result.unwrap(); 354 416 assert_eq!(underscore_lexicons.len(), 2); // Only direct children (chat, key) 355 - 356 - let nsids: Vec<&str> = underscore_lexicons.iter().map(|(nsid, _)| nsid.as_str()).collect(); 417 + 418 + let nsids: Vec<&str> = underscore_lexicons 419 + .iter() 420 + .map(|(nsid, _)| nsid.as_str()) 421 + .collect(); 357 422 assert!(nsids.contains(&"place.stream.chat")); 358 423 assert!(nsids.contains(&"place.stream.key")); 359 424 assert!(!nsids.contains(&"place.stream.chat.profile"));
+16 -12
mlf-lsp/src/context.rs
··· 35 35 if parts.len() >= 2 && !parts[1].contains('(') { 36 36 return CompletionContext::Annotation; 37 37 } 38 - } else if after_at.ends_with(',') || (after_at.contains(',') && !after_at.contains(':')) { 38 + } else if after_at.ends_with(',') || (after_at.contains(',') && !after_at.contains(':')) 39 + { 39 40 // We're completing another selector: @rust, or @rust,typescript, 40 41 return CompletionContext::AnnotationSelector; 41 42 } else if !after_at.is_empty() && !after_at.contains(':') && !after_at.contains('(') { ··· 117 118 118 119 #[test] 119 120 fn test_use_statement() { 120 - assert_eq!( 121 - detect_context("use "), 122 - CompletionContext::UseStatement 123 - ); 124 - assert_eq!( 125 - detect_context("use com."), 126 - CompletionContext::UseStatement 127 - ); 121 + assert_eq!(detect_context("use "), CompletionContext::UseStatement); 122 + assert_eq!(detect_context("use com."), CompletionContext::UseStatement); 128 123 assert_eq!( 129 124 detect_context("use com.atproto."), 130 125 CompletionContext::UseStatement ··· 170 165 171 166 #[test] 172 167 fn test_annotation_selector() { 173 - assert_eq!(detect_context("@rust,"), CompletionContext::AnnotationSelector); 174 - assert_eq!(detect_context("@rust,typescript,"), CompletionContext::AnnotationSelector); 168 + assert_eq!( 169 + detect_context("@rust,"), 170 + CompletionContext::AnnotationSelector 171 + ); 172 + assert_eq!( 173 + detect_context("@rust,typescript,"), 174 + CompletionContext::AnnotationSelector 175 + ); 175 176 } 176 177 177 178 #[test] 178 179 fn test_annotation_after_selector() { 179 180 assert_eq!(detect_context("@rust:"), CompletionContext::Annotation); 180 - assert_eq!(detect_context("@rust,typescript:"), CompletionContext::Annotation); 181 + assert_eq!( 182 + detect_context("@rust,typescript:"), 183 + CompletionContext::Annotation 184 + ); 181 185 } 182 186 }
+7 -3
mlf-lsp/src/main.rs
··· 8 8 std::panic::set_hook(Box::new(|panic_info| { 9 9 eprintln!("LSP PANIC: {:?}", panic_info); 10 10 if let Some(location) = panic_info.location() { 11 - eprintln!(" at {}:{}:{}", location.file(), location.line(), location.column()); 11 + eprintln!( 12 + " at {}:{}:{}", 13 + location.file(), 14 + location.line(), 15 + location.column() 16 + ); 12 17 } 13 18 if let Some(message) = panic_info.payload().downcast_ref::<&str>() { 14 19 eprintln!(" message: {}", message); ··· 20 25 // Initialize logging with debug level 21 26 tracing_subscriber::fmt() 22 27 .with_env_filter( 23 - EnvFilter::try_from_default_env() 24 - .unwrap_or_else(|_| EnvFilter::new("debug")) 28 + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug")), 25 29 ) 26 30 .with_writer(std::io::stderr) 27 31 .init();
+1 -2
mlf-lsp/src/namespace_completion.rs
··· 1 + use mlf_lang::Workspace; 1 2 /// Shared namespace path completion logic 2 3 /// 3 4 /// This module provides utilities for completing namespace paths in both 4 5 /// `use` statements and type positions (e.g., `com.atproto.repo.strongRef`). 5 - 6 6 use std::collections::{HashMap, HashSet}; 7 7 use tower_lsp::lsp_types::*; 8 - use mlf_lang::Workspace; 9 8 10 9 use crate::server::DocumentState; 11 10
+387 -157
mlf-lsp/src/server.rs
··· 1 + use mlf_lang::Workspace; 2 + use mlf_lang::ast::*; 1 3 use std::collections::HashMap; 2 4 use std::path::PathBuf; 3 - use mlf_lang::ast::*; 4 - use mlf_lang::Workspace; 5 5 use tower_lsp::jsonrpc::Result; 6 6 use tower_lsp::lsp_types::*; 7 7 use tower_lsp::{Client, LanguageServer}; 8 8 9 - use crate::context::{detect_context, CompletionContext as MlfCompletionContext}; 9 + use crate::context::{CompletionContext as MlfCompletionContext, detect_context}; 10 10 use crate::namespace_completion; 11 11 use crate::utils::*; 12 12 ··· 91 91 92 92 // Try to update workspace with partial lexicon if available 93 93 let mut diagnostics = if let Some(ref lex) = partial_lexicon { 94 - self.update_workspace(uri, lex.clone(), namespace, text).await 94 + self.update_workspace(uri, lex.clone(), namespace, text) 95 + .await 95 96 } else { 96 97 vec![] 97 98 }; ··· 101 102 span_to_range(text, span) 102 103 } else { 103 104 Range { 104 - start: Position { line: 0, character: 0 }, 105 - end: Position { line: 0, character: 0 }, 105 + start: Position { 106 + line: 0, 107 + character: 0, 108 + }, 109 + end: Position { 110 + line: 0, 111 + character: 0, 112 + }, 106 113 } 107 114 }; 108 115 ··· 135 142 None 136 143 } 137 144 138 - async fn update_workspace(&self, _uri: &Url, lexicon: Lexicon, namespace: Option<String>, text: &str) -> Vec<Diagnostic> { 145 + async fn update_workspace( 146 + &self, 147 + _uri: &Url, 148 + lexicon: Lexicon, 149 + namespace: Option<String>, 150 + text: &str, 151 + ) -> Vec<Diagnostic> { 139 152 let mut diagnostics = vec![]; 140 153 141 154 if let Some(ns) = namespace { ··· 175 188 if mlf_diagnostics::get_error_module_namespace_str(&error) == ns { 176 189 // Extract span and message from error variant 177 190 let (span, message, is_unused) = match &error { 178 - ValidationError::DuplicateDefinition { name, second_span, .. } => { 179 - (*second_span, format!("Duplicate definition: {}", name), false) 180 - } 191 + ValidationError::DuplicateDefinition { 192 + name, second_span, .. 193 + } => ( 194 + *second_span, 195 + format!("Duplicate definition: {}", name), 196 + false, 197 + ), 181 198 ValidationError::UndefinedReference { name, span, .. } => { 182 199 (*span, format!("Undefined reference: {}", name), false) 183 200 } 184 201 ValidationError::InvalidConstraint { message, span, .. } => { 185 202 (*span, format!("Invalid constraint: {}", message), false) 186 203 } 187 - ValidationError::TypeMismatch { expected, found, span, .. } => { 188 - (*span, format!("Type mismatch: expected {}, found {}", expected, found), false) 189 - } 190 - ValidationError::ConstraintTooPermissive { message, span, .. } => { 191 - (*span, format!("Constraint too permissive: {}", message), false) 192 - } 204 + ValidationError::TypeMismatch { 205 + expected, 206 + found, 207 + span, 208 + .. 209 + } => ( 210 + *span, 211 + format!( 212 + "Type mismatch: expected {}, found {}", 213 + expected, found 214 + ), 215 + false, 216 + ), 217 + ValidationError::ConstraintTooPermissive { 218 + message, span, .. 219 + } => ( 220 + *span, 221 + format!("Constraint too permissive: {}", message), 222 + false, 223 + ), 193 224 ValidationError::ReservedName { name, span, .. } => { 194 225 (*span, format!("Reserved name: {}", name), false) 195 226 } 196 - ValidationError::AmbiguousMain { name, first_span, .. } => { 197 - (*first_span, format!("Ambiguous main: {}", name), false) 198 - } 199 - ValidationError::MultipleMain { name, first_span, .. } => { 200 - (*first_span, format!("Multiple @main annotations: {}", name), false) 201 - } 227 + ValidationError::AmbiguousMain { 228 + name, first_span, .. 229 + } => (*first_span, format!("Ambiguous main: {}", name), false), 230 + ValidationError::MultipleMain { 231 + name, first_span, .. 232 + } => ( 233 + *first_span, 234 + format!("Multiple @main annotations: {}", name), 235 + false, 236 + ), 202 237 ValidationError::ConflictNotAllowed { name, span, .. } => { 203 238 (*span, format!("Conflict not allowed: {}", name), false) 204 239 } 205 - ValidationError::CircularImport { cycle, span, .. } => { 206 - (*span, format!("Circular import: {}", cycle.join(" -> ")), false) 207 - } 240 + ValidationError::CircularImport { cycle, span, .. } => ( 241 + *span, 242 + format!("Circular import: {}", cycle.join(" -> ")), 243 + false, 244 + ), 208 245 ValidationError::UnusedImport { name, span, .. } => { 209 246 (*span, format!("Unused import: {}", name), true) 210 247 } ··· 214 251 215 252 diagnostics.push(Diagnostic { 216 253 range, 217 - severity: Some(if is_unused { DiagnosticSeverity::HINT } else { DiagnosticSeverity::ERROR }), 254 + severity: Some(if is_unused { 255 + DiagnosticSeverity::HINT 256 + } else { 257 + DiagnosticSeverity::ERROR 258 + }), 218 259 code: None, 219 260 code_description: None, 220 261 source: Some("mlf".to_string()), ··· 249 290 let std_dir = global_mlf_dir.join("lexicons").join("mlf"); 250 291 251 292 self.client 252 - .log_message(MessageType::INFO, format!("Global MLF directory: {}", global_mlf_dir.display())) 293 + .log_message( 294 + MessageType::INFO, 295 + format!("Global MLF directory: {}", global_mlf_dir.display()), 296 + ) 253 297 .await; 254 298 255 299 // Ensure std directory exists and has files 256 300 if !std_dir.join("prelude.mlf").exists() { 257 301 self.client 258 - .log_message(MessageType::INFO, "Std library not found in ~/.mlf/lexicons/mlf/, extracting embedded files...") 302 + .log_message( 303 + MessageType::INFO, 304 + "Std library not found in ~/.mlf/lexicons/mlf/, extracting embedded files...", 305 + ) 259 306 .await; 260 307 261 308 // Create directory 262 309 if let Err(e) = std::fs::create_dir_all(&std_dir) { 263 310 self.client 264 - .log_message(MessageType::ERROR, format!("Failed to create ~/.mlf/lexicons/mlf/: {}", e)) 311 + .log_message( 312 + MessageType::ERROR, 313 + format!("Failed to create ~/.mlf/lexicons/mlf/: {}", e), 314 + ) 265 315 .await; 266 316 return; 267 317 } ··· 270 320 self.extract_embedded_std_to_directory(&std_dir).await; 271 321 } else { 272 322 self.client 273 - .log_message(MessageType::INFO, "Using existing std library from ~/.mlf/lexicons/mlf/") 323 + .log_message( 324 + MessageType::INFO, 325 + "Using existing std library from ~/.mlf/lexicons/mlf/", 326 + ) 274 327 .await; 275 328 } 276 329 ··· 283 336 } 284 337 285 338 /// Load fetched lexicons from project's .mlf cache directory 286 - async fn load_project_lexicons(&self, workspace: &mut Workspace) -> std::result::Result<(), String> { 339 + async fn load_project_lexicons( 340 + &self, 341 + workspace: &mut Workspace, 342 + ) -> std::result::Result<(), String> { 287 343 // Try to find project root by looking for mlf.toml 288 344 // We'll check a few common locations 289 345 let possible_roots = vec![ ··· 299 355 300 356 if lexicons_dir.exists() { 301 357 self.client 302 - .log_message(MessageType::INFO, format!("Loading project lexicons from {}", lexicons_dir.display())) 358 + .log_message( 359 + MessageType::INFO, 360 + format!("Loading project lexicons from {}", lexicons_dir.display()), 361 + ) 303 362 .await; 304 363 305 - self.load_lexicons_from_directory(workspace, &lexicons_dir, &lexicons_dir).await?; 364 + self.load_lexicons_from_directory(workspace, &lexicons_dir, &lexicons_dir) 365 + .await?; 306 366 307 367 self.client 308 368 .log_message(MessageType::INFO, "Finished loading project lexicons") ··· 352 412 self.client 353 413 .log_message( 354 414 MessageType::WARNING, 355 - format!("Failed to add module {}: {:?}", namespace, e) 415 + format!("Failed to add module {}: {:?}", namespace, e), 356 416 ) 357 417 .await; 358 418 } ··· 363 423 uri.clone(), 364 424 DocumentState { 365 425 text: contents.clone(), 366 - lexicon: Some(mlf_lang::parser::parse_lexicon(&contents).unwrap()), 426 + lexicon: Some( 427 + mlf_lang::parser::parse_lexicon(&contents).unwrap(), 428 + ), 367 429 namespace: Some(namespace.clone()), 368 430 }, 369 431 ); ··· 371 433 self.client 372 434 .log_message( 373 435 MessageType::INFO, 374 - format!("Loaded project lexicon: {} (namespace: {})", uri, namespace) 436 + format!( 437 + "Loaded project lexicon: {} (namespace: {})", 438 + uri, namespace 439 + ), 375 440 ) 376 441 .await; 377 442 } ··· 464 529 465 530 // Write file 466 531 if let Err(e) = std::fs::write(&file_path, contents_str) { 467 - server.client 532 + server 533 + .client 468 534 .log_message( 469 535 MessageType::ERROR, 470 - format!("Failed to write std file {}: {}", file_path.display(), e) 536 + format!( 537 + "Failed to write std file {}: {}", 538 + file_path.display(), 539 + e 540 + ), 471 541 ) 472 542 .await; 473 543 continue; 474 544 } 475 545 476 - server.client 546 + server 547 + .client 477 548 .log_message( 478 549 MessageType::INFO, 479 - format!("Extracted: {}", file_path.display()) 550 + format!("Extracted: {}", file_path.display()), 480 551 ) 481 552 .await; 482 553 } ··· 508 579 self.client 509 580 .log_message( 510 581 MessageType::INFO, 511 - format!("find_definition_in_workspace: target_name={}, current_namespace={}, path={}", 512 - target_name, current_namespace, path.to_string()) 582 + format!( 583 + "find_definition_in_workspace: target_name={}, current_namespace={}, path={}", 584 + target_name, 585 + current_namespace, 586 + path.to_string() 587 + ), 513 588 ) 514 589 .await; 515 590 ··· 522 597 self.client 523 598 .log_message( 524 599 MessageType::INFO, 525 - format!("Resolved target namespace: {}", target_namespace) 600 + format!("Resolved target namespace: {}", target_namespace), 526 601 ) 527 602 .await; 528 603 ··· 532 607 self.client 533 608 .log_message( 534 609 MessageType::INFO, 535 - format!("Searching {} documents for namespace '{}'", documents.len(), target_namespace) 610 + format!( 611 + "Searching {} documents for namespace '{}'", 612 + documents.len(), 613 + target_namespace 614 + ), 536 615 ) 537 616 .await; 538 617 ··· 541 620 self.client 542 621 .log_message( 543 622 MessageType::INFO, 544 - format!("Checking document {} with namespace '{}'", doc_uri, doc_ns) 623 + format!("Checking document {} with namespace '{}'", doc_uri, doc_ns), 545 624 ) 546 625 .await; 547 626 ··· 549 628 self.client 550 629 .log_message( 551 630 MessageType::INFO, 552 - format!("Found matching namespace! Searching for item '{}'", target_name) 631 + format!( 632 + "Found matching namespace! Searching for item '{}'", 633 + target_name 634 + ), 553 635 ) 554 636 .await; 555 637 ··· 572 654 self.client 573 655 .log_message( 574 656 MessageType::INFO, 575 - format!("Found definition of '{}' in {}", target_name, doc_uri) 657 + format!( 658 + "Found definition of '{}' in {}", 659 + target_name, doc_uri 660 + ), 576 661 ) 577 662 .await; 578 663 ··· 587 672 self.client 588 673 .log_message( 589 674 MessageType::INFO, 590 - format!("Definition not found for '{}'", target_name) 675 + format!("Definition not found for '{}'", target_name), 591 676 ) 592 677 .await; 593 678 ··· 610 695 // Check if this looks like "use namespace.typename" (old syntax) 611 696 // or "use namespace" (new syntax) 612 697 let (target_namespace, target_type) = if let UseImports::Items(items) = &use_stmt.imports { 613 - if items.len() == 1 && path.segments.len() >= 2 614 - && items[0].name.name == path.segments.last().unwrap().name { 698 + if items.len() == 1 699 + && path.segments.len() >= 2 700 + && items[0].name.name == path.segments.last().unwrap().name 701 + { 615 702 // Old syntax: use a.b.c as Foo 616 703 // Navigate to type "c" in namespace "a.b" 617 704 let ns = path.segments[..path.segments.len() - 1] ··· 631 718 (path.to_string(), None) 632 719 }; 633 720 634 - self.find_definition_in_namespace(&target_namespace, target_type.as_deref().unwrap_or("")).await 721 + self.find_definition_in_namespace(&target_namespace, target_type.as_deref().unwrap_or("")) 722 + .await 635 723 } 636 724 637 725 /// Find a definition in a specific namespace ··· 645 733 self.client 646 734 .log_message( 647 735 MessageType::INFO, 648 - format!("find_definition_in_namespace: namespace='{}', name='{}'", target_namespace, target_name) 736 + format!( 737 + "find_definition_in_namespace: namespace='{}', name='{}'", 738 + target_namespace, target_name 739 + ), 649 740 ) 650 741 .await; 651 742 ··· 829 920 if let Err(e) = self.load_project_lexicons(&mut ws).await { 830 921 tracing::warn!("Failed to load project lexicons: {}", e); 831 922 self.client 832 - .log_message(MessageType::WARNING, format!("Failed to load project lexicons: {}", e)) 923 + .log_message( 924 + MessageType::WARNING, 925 + format!("Failed to load project lexicons: {}", e), 926 + ) 833 927 .await; 834 928 } 835 929 ··· 884 978 885 979 async fn did_close(&self, params: DidCloseTextDocumentParams) { 886 980 // Remove document from storage 887 - self.documents.write().await.remove(&params.text_document.uri); 981 + self.documents 982 + .write() 983 + .await 984 + .remove(&params.text_document.uri); 888 985 889 986 // TODO: Could rebuild workspace without this module 890 987 } ··· 914 1011 915 1012 if let Some(annotations) = annotations_to_check { 916 1013 for annotation in annotations { 917 - if annotation.span.start <= offset && offset <= annotation.span.end { 1014 + if annotation.span.start <= offset && offset <= annotation.span.end 1015 + { 918 1016 // Build hover content for the annotation 919 1017 let mut contents = vec![]; 920 1018 ··· 922 1020 let annotation_display = if annotation.selectors.is_empty() { 923 1021 format!("@{}", annotation.name.name) 924 1022 } else { 925 - let selector_names: Vec<_> = annotation.selectors.iter() 1023 + let selector_names: Vec<_> = annotation 1024 + .selectors 1025 + .iter() 926 1026 .map(|s| s.name.as_str()) 927 1027 .collect(); 928 - format!("@{}:{}", selector_names.join(","), annotation.name.name) 1028 + format!( 1029 + "@{}:{}", 1030 + selector_names.join(","), 1031 + annotation.name.name 1032 + ) 929 1033 }; 930 1034 931 1035 contents.push(MarkedString::LanguageString(LanguageString { ··· 936 1040 // Add description based on annotation name 937 1041 let description = match annotation.name.name.as_str() { 938 1042 "deprecated" => "Marks this definition as deprecated", 939 - "main" => "Designates this as the main definition for conflict resolution", 940 - "key" => "Specifies the record key type (e.g., 'tid', 'literal:self')", 941 - "encoding" => "Specifies MIME type encoding for XRPC (e.g., 'application/json', 'application/cbor')", 1043 + "main" => { 1044 + "Designates this as the main definition for conflict resolution" 1045 + } 1046 + "key" => { 1047 + "Specifies the record key type (e.g., 'tid', 'literal:self')" 1048 + } 1049 + "encoding" => { 1050 + "Specifies MIME type encoding for XRPC (e.g., 'application/json', 'application/cbor')" 1051 + } 942 1052 "since" => "Indicates the version when this was added", 943 1053 "doc" => "Provides a documentation URL", 944 1054 "validate" => "Specifies validation rules", 945 1055 "cache" => "Defines caching strategy", 946 1056 "indexed" => "Marks this field as indexed", 947 - "sensitive" => "Marks this field as containing sensitive data (e.g., PII)", 948 - "const" => "Extension field (literal). `@const(key, value)` emits `key: value` verbatim in the JSON Lexicon. Use on `self {}` for top-level fields, or any item for per-item fields.", 949 - "reference" => "Extension field (named-type reference). `@reference(key, path)` resolves `path` through the workspace and emits the resulting NSID string under `key`.", 1057 + "sensitive" => { 1058 + "Marks this field as containing sensitive data (e.g., PII)" 1059 + } 1060 + "const" => { 1061 + "Extension field (literal). `@const(key, value)` emits `key: value` verbatim in the JSON Lexicon. Use on `self {}` for top-level fields, or any item for per-item fields." 1062 + } 1063 + "reference" => { 1064 + "Extension field (named-type reference). `@reference(key, path)` resolves `path` through the workspace and emits the resulting NSID string under `key`." 1065 + } 950 1066 _ => "Custom annotation", 951 1067 }; 952 1068 ··· 956 1072 if !annotation.selectors.is_empty() { 957 1073 let selector_info = format!( 958 1074 "This annotation applies to: {}", 959 - annotation.selectors.iter() 1075 + annotation 1076 + .selectors 1077 + .iter() 960 1078 .map(|s| s.name.as_str()) 961 1079 .collect::<Vec<_>>() 962 1080 .join(", ") ··· 964 1082 contents.push(MarkedString::String(selector_info)); 965 1083 } else { 966 1084 contents.push(MarkedString::String( 967 - "This annotation is visible to all generators".to_string() 1085 + "This annotation is visible to all generators" 1086 + .to_string(), 968 1087 )); 969 1088 } 970 1089 ··· 981 1100 Item::Record(r) => { 982 1101 for field in &r.fields { 983 1102 for annotation in &field.annotations { 984 - if annotation.span.start <= offset && offset <= annotation.span.end { 985 - let annotation_display = if annotation.selectors.is_empty() { 986 - format!("@{}", annotation.name.name) 987 - } else { 988 - let selector_names: Vec<_> = annotation.selectors.iter() 989 - .map(|s| s.name.as_str()) 990 - .collect(); 991 - format!("@{}:{}", selector_names.join(","), annotation.name.name) 992 - }; 1103 + if annotation.span.start <= offset 1104 + && offset <= annotation.span.end 1105 + { 1106 + let annotation_display = 1107 + if annotation.selectors.is_empty() { 1108 + format!("@{}", annotation.name.name) 1109 + } else { 1110 + let selector_names: Vec<_> = annotation 1111 + .selectors 1112 + .iter() 1113 + .map(|s| s.name.as_str()) 1114 + .collect(); 1115 + format!( 1116 + "@{}:{}", 1117 + selector_names.join(","), 1118 + annotation.name.name 1119 + ) 1120 + }; 993 1121 994 1122 return Ok(Some(Hover { 995 1123 contents: HoverContents::Scalar( 996 1124 MarkedString::LanguageString(LanguageString { 997 1125 language: "mlf".to_string(), 998 - value: format!("Field annotation: {}", annotation_display), 999 - }) 1126 + value: format!( 1127 + "Field annotation: {}", 1128 + annotation_display 1129 + ), 1130 + }), 1000 1131 ), 1001 1132 range: None, 1002 1133 })); ··· 1049 1180 Item::Record(r) => { 1050 1181 if let Some(field) = find_field_at_offset(&r.fields, offset) { 1051 1182 let field_type = format_type(&field.ty); 1052 - let opt = if field.optional { "optional" } else { "required" }; 1053 - contents.push(MarkedString::String( 1054 - format!("Field: {} ({})", field_type, opt) 1055 - )); 1183 + let opt = if field.optional { 1184 + "optional" 1185 + } else { 1186 + "required" 1187 + }; 1188 + contents.push(MarkedString::String(format!( 1189 + "Field: {} ({})", 1190 + field_type, opt 1191 + ))); 1056 1192 } 1057 1193 } 1058 1194 Item::InlineType(i) => { 1059 - contents.push(MarkedString::String( 1060 - format!("Type: {}", format_type(&i.ty)) 1061 - )); 1195 + contents.push(MarkedString::String(format!( 1196 + "Type: {}", 1197 + format_type(&i.ty) 1198 + ))); 1062 1199 } 1063 1200 Item::DefType(d) => { 1064 - contents.push(MarkedString::String( 1065 - format!("Type: {}", format_type(&d.ty)) 1066 - )); 1201 + contents.push(MarkedString::String(format!( 1202 + "Type: {}", 1203 + format_type(&d.ty) 1204 + ))); 1067 1205 } 1068 1206 Item::Query(q) => { 1069 1207 if let Some(field) = find_field_at_offset(&q.params, offset) { 1070 1208 let field_type = format_type(&field.ty); 1071 - let opt = if field.optional { "optional" } else { "required" }; 1072 - contents.push(MarkedString::String( 1073 - format!("Parameter: {} ({})", field_type, opt) 1074 - )); 1209 + let opt = if field.optional { 1210 + "optional" 1211 + } else { 1212 + "required" 1213 + }; 1214 + contents.push(MarkedString::String(format!( 1215 + "Parameter: {} ({})", 1216 + field_type, opt 1217 + ))); 1075 1218 } 1076 1219 } 1077 1220 Item::Procedure(p) => { 1078 1221 if let Some(field) = find_field_at_offset(&p.params, offset) { 1079 1222 let field_type = format_type(&field.ty); 1080 - let opt = if field.optional { "optional" } else { "required" }; 1081 - contents.push(MarkedString::String( 1082 - format!("Parameter: {} ({})", field_type, opt) 1083 - )); 1223 + let opt = if field.optional { 1224 + "optional" 1225 + } else { 1226 + "required" 1227 + }; 1228 + contents.push(MarkedString::String(format!( 1229 + "Parameter: {} ({})", 1230 + field_type, opt 1231 + ))); 1084 1232 } 1085 1233 } 1086 1234 _ => {} ··· 1121 1269 MarkedString::LanguageString(LanguageString { 1122 1270 language: "mlf".to_string(), 1123 1271 value: format!("type {}", path.to_string()), 1124 - }) 1272 + }), 1125 1273 ), 1126 1274 range: None, 1127 1275 })); ··· 1149 1297 let mut completions = vec![]; 1150 1298 1151 1299 // Detect context 1152 - let text_before_cursor = if let Some(offset) = position_to_offset(&doc_state.text, position) { 1153 - &doc_state.text[..offset] 1154 - } else { 1155 - "" 1156 - }; 1300 + let text_before_cursor = 1301 + if let Some(offset) = position_to_offset(&doc_state.text, position) { 1302 + &doc_state.text[..offset] 1303 + } else { 1304 + "" 1305 + }; 1157 1306 1158 1307 let context = detect_context(text_before_cursor); 1159 1308 tracing::debug!("Context detected: {:?}", context); ··· 1174 1323 let has_trailing_dot = after_use.ends_with('.'); 1175 1324 let partial_path = after_use.trim_end_matches('.'); // Remove trailing dot for matching 1176 1325 1177 - tracing::debug!("Partial path: '{}', has_trailing_dot: {}", partial_path, has_trailing_dot); 1326 + tracing::debug!( 1327 + "Partial path: '{}', has_trailing_dot: {}", 1328 + partial_path, 1329 + has_trailing_dot 1330 + ); 1178 1331 1179 1332 // Calculate the range to replace 1180 - let use_start_offset = text_before_cursor.rfind("use ").map(|i| i + 4).unwrap_or(0); 1333 + let use_start_offset = 1334 + text_before_cursor.rfind("use ").map(|i| i + 4).unwrap_or(0); 1181 1335 let use_start_pos = offset_to_position(&doc_state.text, use_start_offset); 1182 1336 1183 1337 // Use shared namespace completion logic ··· 1229 1383 MlfCompletionContext::TypePosition => { 1230 1384 // Extract text after the last ':' to detect if user is typing a namespace path 1231 1385 let last_line = text_before_cursor.lines().last().unwrap_or(""); 1232 - let after_colon = last_line.rfind(':') 1386 + let after_colon = last_line 1387 + .rfind(':') 1233 1388 .map(|idx| &last_line[idx + 1..]) 1234 1389 .unwrap_or(""); 1235 1390 ··· 1239 1394 // Check if user is typing a path (contains a dot) 1240 1395 let is_typing_path = after_colon_trimmed.contains('.'); 1241 1396 1242 - tracing::debug!("Type position: after_colon_trimmed='{}', is_typing_path={}", after_colon_trimmed, is_typing_path); 1397 + tracing::debug!( 1398 + "Type position: after_colon_trimmed='{}', is_typing_path={}", 1399 + after_colon_trimmed, 1400 + is_typing_path 1401 + ); 1243 1402 1244 1403 if is_typing_path { 1245 1404 // User is typing a namespace path like "com.atproto." ··· 1247 1406 let has_trailing_dot = after_colon_trimmed.ends_with('.'); 1248 1407 let partial_path = after_colon_trimmed.trim_end_matches('.'); 1249 1408 1250 - tracing::debug!("Namespace path mode: partial_path='{}', has_trailing_dot={}", partial_path, has_trailing_dot); 1409 + tracing::debug!( 1410 + "Namespace path mode: partial_path='{}', has_trailing_dot={}", 1411 + partial_path, 1412 + has_trailing_dot 1413 + ); 1251 1414 1252 1415 // Calculate the range to replace (from after ':' and any spaces to cursor) 1253 - let colon_offset = text_before_cursor.rfind(':').map(|i| i + 1).unwrap_or(0); 1416 + let colon_offset = 1417 + text_before_cursor.rfind(':').map(|i| i + 1).unwrap_or(0); 1254 1418 // Skip leading spaces to get to where the actual path starts 1255 1419 let space_count = after_colon.len() - after_colon_trimmed.len(); 1256 - let replace_start_pos = offset_to_position(&doc_state.text, colon_offset + space_count); 1420 + let replace_start_pos = 1421 + offset_to_position(&doc_state.text, colon_offset + space_count); 1257 1422 1258 1423 let workspace_guard = self.workspace.read().await; 1259 1424 if let Some(workspace) = workspace_guard.as_ref() { ··· 1274 1439 tracing::debug!("Local type mode"); 1275 1440 1276 1441 // Primitive types 1277 - let primitives = vec![ 1278 - "null", "boolean", "integer", "string", "bytes", "blob", 1279 - ]; 1442 + let primitives = 1443 + vec!["null", "boolean", "integer", "string", "bytes", "blob"]; 1280 1444 1281 1445 for prim in primitives { 1282 1446 completions.push(CompletionItem { ··· 1334 1498 let workspace_guard = self.workspace.read().await; 1335 1499 if let Some(workspace) = workspace_guard.as_ref() { 1336 1500 let imports = workspace.get_imports(current_namespace); 1337 - tracing::debug!("Found {} imports for namespace '{}'", imports.len(), current_namespace); 1501 + tracing::debug!( 1502 + "Found {} imports for namespace '{}'", 1503 + imports.len(), 1504 + current_namespace 1505 + ); 1338 1506 1339 1507 for (local_name, original_path) in imports { 1340 1508 // Format the original path for display ··· 1386 1554 let annotations = vec![ 1387 1555 ("deprecated", "Mark as deprecated"), 1388 1556 ("main", "Main definition for conflict resolution"), 1389 - ("key", "Specify record key type (e.g., @key(\"literal:self\"))"), 1390 - ("encoding", "Specify MIME type encoding (e.g., @encoding(\"application/cbor\"))"), 1557 + ( 1558 + "key", 1559 + "Specify record key type (e.g., @key(\"literal:self\"))", 1560 + ), 1561 + ( 1562 + "encoding", 1563 + "Specify MIME type encoding (e.g., @encoding(\"application/cbor\"))", 1564 + ), 1391 1565 ("since", "Version when added (e.g., @since(1, 2, 0))"), 1392 - ("doc", "Documentation URL (e.g., @doc(\"https://example.com\"))"), 1393 - ("validate", "Validation rules (e.g., @validate(min: 0, max: 100))"), 1566 + ( 1567 + "doc", 1568 + "Documentation URL (e.g., @doc(\"https://example.com\"))", 1569 + ), 1570 + ( 1571 + "validate", 1572 + "Validation rules (e.g., @validate(min: 0, max: 100))", 1573 + ), 1394 1574 ("cache", "Caching strategy (e.g., @cache(ttl: 3600))"), 1395 1575 ("indexed", "Mark field as indexed"), 1396 1576 ("sensitive", "Mark field as containing sensitive data"), ··· 1429 1609 MlfCompletionContext::TopLevel => { 1430 1610 // Suggest keywords for top-level declarations 1431 1611 let keywords = vec![ 1432 - ("record", CompletionItemKind::KEYWORD, "Define a record type"), 1433 - ("inline type", CompletionItemKind::KEYWORD, "Define an inline type"), 1612 + ( 1613 + "record", 1614 + CompletionItemKind::KEYWORD, 1615 + "Define a record type", 1616 + ), 1617 + ( 1618 + "inline type", 1619 + CompletionItemKind::KEYWORD, 1620 + "Define an inline type", 1621 + ), 1434 1622 ("def type", CompletionItemKind::KEYWORD, "Define a def type"), 1435 1623 ("token", CompletionItemKind::KEYWORD, "Define a token"), 1436 1624 ("query", CompletionItemKind::KEYWORD, "Define a query"), 1437 - ("procedure", CompletionItemKind::KEYWORD, "Define a procedure"), 1438 - ("subscription", CompletionItemKind::KEYWORD, "Define a subscription"), 1439 - ("use", CompletionItemKind::KEYWORD, "Import types from another module"), 1440 - ("self", CompletionItemKind::KEYWORD, "Lexicon-as-item — attach top-level docs and @const / @reference extensions"), 1625 + ( 1626 + "procedure", 1627 + CompletionItemKind::KEYWORD, 1628 + "Define a procedure", 1629 + ), 1630 + ( 1631 + "subscription", 1632 + CompletionItemKind::KEYWORD, 1633 + "Define a subscription", 1634 + ), 1635 + ( 1636 + "use", 1637 + CompletionItemKind::KEYWORD, 1638 + "Import types from another module", 1639 + ), 1640 + ( 1641 + "self", 1642 + CompletionItemKind::KEYWORD, 1643 + "Lexicon-as-item — attach top-level docs and @const / @reference extensions", 1644 + ), 1441 1645 ]; 1442 1646 1443 1647 for (label, kind, detail) in keywords { ··· 1473 1677 self.client 1474 1678 .log_message( 1475 1679 MessageType::INFO, 1476 - format!("Go to definition request at {}:{}:{}", uri, position.line, position.character), 1680 + format!( 1681 + "Go to definition request at {}:{}:{}", 1682 + uri, position.line, position.character 1683 + ), 1477 1684 ) 1478 1685 .await; 1479 1686 ··· 1501 1708 self.client 1502 1709 .log_message( 1503 1710 MessageType::INFO, 1504 - format!("Found use statement at cursor: {}", use_stmt.path.to_string()) 1711 + format!( 1712 + "Found use statement at cursor: {}", 1713 + use_stmt.path.to_string() 1714 + ), 1505 1715 ) 1506 1716 .await; 1507 1717 ··· 1511 1721 1512 1722 // Handle go-to-definition for use statements 1513 1723 if let Some(ref current_ns) = current_namespace { 1514 - if let Some((def_uri, def_span)) = 1515 - self.find_definition_in_workspace_for_use(use_stmt, current_ns).await { 1516 - 1724 + if let Some((def_uri, def_span)) = self 1725 + .find_definition_in_workspace_for_use(use_stmt, current_ns) 1726 + .await 1727 + { 1517 1728 let documents = self.documents.read().await; 1518 1729 if let Some(target_doc) = documents.get(&def_uri) { 1519 1730 let range = span_to_range(&target_doc.text, def_span); ··· 1521 1732 self.client 1522 1733 .log_message( 1523 1734 MessageType::INFO, 1524 - format!("Returning use statement definition from: {}", def_uri) 1735 + format!( 1736 + "Returning use statement definition from: {}", 1737 + def_uri 1738 + ), 1525 1739 ) 1526 1740 .await; 1527 1741 ··· 1539 1753 // Check if cursor is on an imported item name 1540 1754 if let UseImports::Items(items) = &use_stmt.imports { 1541 1755 for import_item in items { 1542 - if import_item.name.span.start <= offset && offset <= import_item.name.span.end { 1756 + if import_item.name.span.start <= offset 1757 + && offset <= import_item.name.span.end 1758 + { 1543 1759 self.client 1544 1760 .log_message( 1545 1761 MessageType::INFO, 1546 - format!("Found use item at cursor: {}", import_item.name.name) 1762 + format!( 1763 + "Found use item at cursor: {}", 1764 + import_item.name.name 1765 + ), 1547 1766 ) 1548 1767 .await; 1549 1768 ··· 1553 1772 let target_namespace = use_stmt.path.to_string(); 1554 1773 let item_name = if import_item.name.name == "main" { 1555 1774 // Special case: "main" resolves to namespace suffix 1556 - target_namespace.split('.').last().unwrap_or(&import_item.name.name) 1775 + target_namespace 1776 + .split('.') 1777 + .last() 1778 + .unwrap_or(&import_item.name.name) 1557 1779 } else { 1558 1780 &import_item.name.name 1559 1781 }; 1560 1782 1561 - if let Some((def_uri, def_span)) = 1562 - self.find_definition_in_namespace(&target_namespace, item_name).await { 1563 - 1783 + if let Some((def_uri, def_span)) = self 1784 + .find_definition_in_namespace( 1785 + &target_namespace, 1786 + item_name, 1787 + ) 1788 + .await 1789 + { 1564 1790 let documents = self.documents.read().await; 1565 1791 if let Some(target_doc) = documents.get(&def_uri) { 1566 - let range = span_to_range(&target_doc.text, def_span); 1792 + let range = 1793 + span_to_range(&target_doc.text, def_span); 1567 1794 1568 1795 return Ok(Some(GotoDefinitionResponse::Scalar( 1569 1796 Location { ··· 1583 1810 // Find type reference at this position 1584 1811 for item in &lexicon.items { 1585 1812 let type_to_check = match item { 1586 - Item::Record(r) => { 1587 - find_field_at_offset(&r.fields, offset).map(|f| &f.ty) 1588 - } 1813 + Item::Record(r) => find_field_at_offset(&r.fields, offset).map(|f| &f.ty), 1589 1814 Item::InlineType(i) => Some(&i.ty), 1590 1815 Item::DefType(d) => Some(&d.ty), 1591 - Item::Query(q) => { 1592 - find_field_at_offset(&q.params, offset).map(|f| &f.ty) 1593 - } 1816 + Item::Query(q) => find_field_at_offset(&q.params, offset).map(|f| &f.ty), 1594 1817 Item::Procedure(p) => { 1595 1818 find_field_at_offset(&p.params, offset).map(|f| &f.ty) 1596 1819 } ··· 1598 1821 }; 1599 1822 1600 1823 if let Some(ty) = type_to_check { 1601 - if let Some(Type::Reference { path, .. }) = find_type_at_offset(ty, offset) { 1824 + if let Some(Type::Reference { path, .. }) = find_type_at_offset(ty, offset) 1825 + { 1602 1826 self.client 1603 1827 .log_message( 1604 1828 MessageType::INFO, 1605 - format!("Found reference at cursor: {}", path.to_string()) 1829 + format!("Found reference at cursor: {}", path.to_string()), 1606 1830 ) 1607 1831 .await; 1608 1832 ··· 1616 1840 self.client 1617 1841 .log_message( 1618 1842 MessageType::INFO, 1619 - format!("Target name: {}, path segments: {}", target_name, path.segments.len()) 1843 + format!( 1844 + "Target name: {}, path segments: {}", 1845 + target_name, 1846 + path.segments.len() 1847 + ), 1620 1848 ) 1621 1849 .await; 1622 1850 ··· 1639 1867 self.client 1640 1868 .log_message( 1641 1869 MessageType::INFO, 1642 - format!("Found definition in current file") 1870 + format!("Found definition in current file"), 1643 1871 ) 1644 1872 .await; 1645 1873 1646 - return Ok(Some(GotoDefinitionResponse::Scalar( 1647 - Location { 1648 - uri: uri.clone(), 1649 - range, 1650 - }, 1651 - ))); 1874 + return Ok(Some(GotoDefinitionResponse::Scalar(Location { 1875 + uri: uri.clone(), 1876 + range, 1877 + }))); 1652 1878 } 1653 1879 } 1654 1880 1655 1881 self.client 1656 1882 .log_message( 1657 1883 MessageType::INFO, 1658 - format!("Not found in current file, searching workspace...") 1884 + format!("Not found in current file, searching workspace..."), 1659 1885 ) 1660 1886 .await; 1661 1887 ··· 1664 1890 self.client 1665 1891 .log_message( 1666 1892 MessageType::INFO, 1667 - format!("Current namespace: {}", current_ns) 1893 + format!("Current namespace: {}", current_ns), 1668 1894 ) 1669 1895 .await; 1670 1896 1671 - if let Some((def_uri, def_span)) = 1672 - self.find_definition_in_workspace(target_name, current_ns, path).await { 1673 - 1897 + if let Some((def_uri, def_span)) = self 1898 + .find_definition_in_workspace(target_name, current_ns, path) 1899 + .await 1900 + { 1674 1901 // Get text for span conversion 1675 1902 let documents = self.documents.read().await; 1676 1903 if let Some(target_doc) = documents.get(&def_uri) { ··· 1679 1906 self.client 1680 1907 .log_message( 1681 1908 MessageType::INFO, 1682 - format!("Returning definition from workspace: {}", def_uri) 1909 + format!( 1910 + "Returning definition from workspace: {}", 1911 + def_uri 1912 + ), 1683 1913 ) 1684 1914 .await; 1685 1915 ··· 1695 1925 self.client 1696 1926 .log_message( 1697 1927 MessageType::WARNING, 1698 - format!("No current namespace available") 1928 + format!("No current namespace available"), 1699 1929 ) 1700 1930 .await; 1701 1931 }
+15 -4
mlf-lsp/src/utils.rs
··· 52 52 current_offset += ch.len_utf8(); 53 53 } 54 54 55 - Position { line: line as u32, character: character as u32 } 55 + Position { 56 + line: line as u32, 57 + character: character as u32, 58 + } 56 59 } 57 60 58 61 /// Convert MLF Span to LSP Range ··· 121 124 122 125 /// Find field at offset within a record/query/procedure 123 126 pub fn find_field_at_offset(fields: &[Field], offset: usize) -> Option<&Field> { 124 - fields.iter().find(|field| offset_in_span(offset, field.span)) 127 + fields 128 + .iter() 129 + .find(|field| offset_in_span(offset, field.span)) 125 130 } 126 131 127 132 /// Get the item name ··· 185 190 format!("{{ {} }}", fields_str) 186 191 } 187 192 Type::Parenthesized { inner, .. } => format!("({})", format_type(inner)), 188 - Type::Constrained { base, constraints, .. } => { 189 - format!("{} constrained {{ {} constraints }}", format_type(base), constraints.len()) 193 + Type::Constrained { 194 + base, constraints, .. 195 + } => { 196 + format!( 197 + "{} constrained {{ {} constraints }}", 198 + format_type(base), 199 + constraints.len() 200 + ) 190 201 } 191 202 Type::Unknown { .. } => "unknown".to_string(), 192 203 }
+88 -38
mlf-validation/src/lib.rs
··· 1 + use langtag::LangTag; 1 2 use mlf_lang::ast::*; 3 + use regex::Regex; 2 4 use serde_json::Value as JsonValue; 3 5 use std::fmt; 6 + use time::OffsetDateTime; 7 + use time::format_description::well_known::Rfc3339; 4 8 use unicode_segmentation::UnicodeSegmentation; 5 - use regex::Regex; 6 9 use url::Url; 7 - use time::format_description::well_known::Rfc3339; 8 - use time::OffsetDateTime; 9 - use langtag::LangTag; 10 10 11 11 #[derive(Debug, Clone)] 12 12 pub struct ValidationError { ··· 45 45 Item::Query(_) | Item::Procedure(_) => { 46 46 errors.push(ValidationError { 47 47 path: "$".to_string(), 48 - message: "Cannot validate records against query/procedure definitions".to_string(), 48 + message: "Cannot validate records against query/procedure definitions" 49 + .to_string(), 49 50 }); 50 51 } 51 52 _ => { ··· 90 91 Type::Primitive { kind, .. } => { 91 92 self.validate_primitive(value, *kind, path, errors); 92 93 } 93 - Type::Constrained { base, constraints, .. } => { 94 + Type::Constrained { 95 + base, constraints, .. 96 + } => { 94 97 self.validate_against_type(value, base, path, errors); 95 98 self.validate_constraints(value, constraints, path, errors); 96 99 } ··· 246 249 if s.len() < *min { 247 250 errors.push(ValidationError { 248 251 path: path.to_string(), 249 - message: format!("String too short: {} bytes (min: {})", s.len(), min), 252 + message: format!( 253 + "String too short: {} bytes (min: {})", 254 + s.len(), 255 + min 256 + ), 250 257 }); 251 258 } 252 259 } else if let Some(arr) = value.as_array() { ··· 254 261 if arr.len() < *min { 255 262 errors.push(ValidationError { 256 263 path: path.to_string(), 257 - message: format!("Array too short: {} elements (min: {})", arr.len(), min), 264 + message: format!( 265 + "Array too short: {} elements (min: {})", 266 + arr.len(), 267 + min 268 + ), 258 269 }); 259 270 } 260 271 } ··· 264 275 if s.len() > *max { 265 276 errors.push(ValidationError { 266 277 path: path.to_string(), 267 - message: format!("String too long: {} bytes (max: {})", s.len(), max), 278 + message: format!( 279 + "String too long: {} bytes (max: {})", 280 + s.len(), 281 + max 282 + ), 268 283 }); 269 284 } 270 285 } else if let Some(arr) = value.as_array() { ··· 272 287 if arr.len() > *max { 273 288 errors.push(ValidationError { 274 289 path: path.to_string(), 275 - message: format!("Array too long: {} elements (max: {})", arr.len(), max), 290 + message: format!( 291 + "Array too long: {} elements (max: {})", 292 + arr.len(), 293 + max 294 + ), 276 295 }); 277 296 } 278 297 } ··· 284 303 if count < *min { 285 304 errors.push(ValidationError { 286 305 path: path.to_string(), 287 - message: format!("String has too few graphemes: {} (min: {})", count, min), 306 + message: format!( 307 + "String has too few graphemes: {} (min: {})", 308 + count, min 309 + ), 288 310 }); 289 311 } 290 312 } ··· 296 318 if count > *max { 297 319 errors.push(ValidationError { 298 320 path: path.to_string(), 299 - message: format!("String has too many graphemes: {} (max: {})", count, max), 321 + message: format!( 322 + "String has too many graphemes: {} (max: {})", 323 + count, max 324 + ), 300 325 }); 301 326 } 302 327 } ··· 323 348 } 324 349 Constraint::Enum { values, .. } => { 325 350 if let Some(s) = value.as_str() { 326 - let enum_strings: Vec<String> = values.iter().map(|v| match v { 327 - mlf_lang::ast::ValueRef::Literal(lit) => lit.clone(), 328 - mlf_lang::ast::ValueRef::Reference(path) => path.to_string(), 329 - }).collect(); 351 + let enum_strings: Vec<String> = values 352 + .iter() 353 + .map(|v| match v { 354 + mlf_lang::ast::ValueRef::Literal(lit) => lit.clone(), 355 + mlf_lang::ast::ValueRef::Reference(path) => path.to_string(), 356 + }) 357 + .collect(); 330 358 if !enum_strings.contains(&s.to_string()) { 331 359 errors.push(ValidationError { 332 360 path: path.to_string(), ··· 347 375 if !mimes.iter().any(|m| m == mime) { 348 376 errors.push(ValidationError { 349 377 path: path.to_string(), 350 - message: format!("MIME type '{}' not accepted (allowed: {:?})", mime, mimes), 378 + message: format!( 379 + "MIME type '{}' not accepted (allowed: {:?})", 380 + mime, mimes 381 + ), 351 382 }); 352 383 } 353 384 } ··· 488 519 if !matched { 489 520 errors.push(ValidationError { 490 521 path: path.to_string(), 491 - message: format!("Value does not match any type in union ({} variants tried)", types.len()), 522 + message: format!( 523 + "Value does not match any type in union ({} variants tried)", 524 + types.len() 525 + ), 492 526 }); 493 527 } 494 528 } ··· 571 605 // DID format: did:method:method-specific-id 572 606 // method: lowercase letters, numbers 573 607 // method-specific-id: alphanumeric plus . - _ : 574 - let re = Regex::new( 575 - r"^did:[a-z0-9]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$" 576 - ).unwrap(); 608 + let re = Regex::new(r"^did:[a-z0-9]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$").unwrap(); 577 609 re.is_match(value) 578 610 } 579 611 ··· 591 623 if segment.is_empty() 592 624 || segment.starts_with('-') 593 625 || segment.ends_with('-') 594 - || segment.len() > 63 { 626 + || segment.len() > 63 627 + { 595 628 return false; 596 629 } 597 - if !segment.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { 630 + if !segment 631 + .chars() 632 + .all(|c| c.is_ascii_alphanumeric() || c == '-') 633 + { 598 634 return false; 599 635 } 600 636 } ··· 624 660 return false; 625 661 } 626 662 // NSID segments must be lowercase alphanumeric (and hyphen for domain parts) 627 - if !part.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { 663 + if !part 664 + .chars() 665 + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') 666 + { 628 667 return false; 629 668 } 630 669 // Can't start with digit 631 - if part.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) { 670 + if part 671 + .chars() 672 + .next() 673 + .map(|c| c.is_ascii_digit()) 674 + .unwrap_or(false) 675 + { 632 676 return false; 633 677 } 634 678 } ··· 649 693 // CIDv1: starts with 'b' (base32) or 'z' (base58btc) followed by version 650 694 if value.starts_with("Qm") && value.len() == 46 { 651 695 // CIDv0 - all base58btc chars 652 - return value.chars().all(|c| { 653 - c.is_ascii_alphanumeric() && c != '0' && c != 'O' && c != 'I' && c != 'l' 654 - }); 696 + return value 697 + .chars() 698 + .all(|c| c.is_ascii_alphanumeric() && c != '0' && c != 'O' && c != 'I' && c != 'l'); 655 699 } 656 700 657 701 if (value.starts_with('b') || value.starts_with('z')) && value.len() > 10 { ··· 681 725 return false; 682 726 } 683 727 684 - value.chars().all(|c| { 685 - matches!(c, 'a'..='z' | '2'..='7') 686 - }) 728 + value.chars().all(|c| matches!(c, 'a'..='z' | '2'..='7')) 687 729 } 688 730 689 731 /// Validate record-key format ··· 701 743 } 702 744 703 745 // Otherwise, general record key validation 704 - value.chars().all(|c| { 705 - c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '~' || c == '-' 706 - }) 746 + value 747 + .chars() 748 + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '~' || c == '-') 707 749 } 708 750 709 751 #[cfg(test)] ··· 743 785 // Valid AT-URIs 744 786 assert!(validate_at_uri("at://did:plc:abc123")); 745 787 assert!(validate_at_uri("at://did:plc:abc123/com.example.foo")); 746 - assert!(validate_at_uri("at://did:plc:abc123/com.example.foo/abc123")); 747 - assert!(validate_at_uri("at://alice.example.com/com.example.post/abc")); 788 + assert!(validate_at_uri( 789 + "at://did:plc:abc123/com.example.foo/abc123" 790 + )); 791 + assert!(validate_at_uri( 792 + "at://alice.example.com/com.example.post/abc" 793 + )); 748 794 749 795 // Invalid AT-URIs 750 796 assert!(!validate_at_uri("https://example.com")); ··· 797 843 #[test] 798 844 fn test_validate_cid() { 799 845 // Valid CIDs (examples) 800 - assert!(validate_cid("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG")); // CIDv0 801 - assert!(validate_cid("bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku")); // CIDv1 846 + assert!(validate_cid( 847 + "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG" 848 + )); // CIDv0 849 + assert!(validate_cid( 850 + "bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 851 + )); // CIDv1 802 852 803 853 // Invalid CIDs 804 854 assert!(!validate_cid(""));
+5 -2
tests/codegen_integration.rs
··· 14 14 use std::path::Path; 15 15 16 16 fn run_lexicon_test(input_path: &Path) -> datatest_stable::Result<()> { 17 - let test_dir = input_path.parent().ok_or("input.mlf has no parent directory")?; 17 + let test_dir = input_path 18 + .parent() 19 + .ok_or("input.mlf has no parent directory")?; 18 20 let test_name = test_dir 19 21 .file_name() 20 22 .and_then(|s| s.to_str()) ··· 29 31 let input = fs::read_to_string(input_path)?; 30 32 let lexicon = parse_lexicon(&input).map_err(|e| format!("Failed to parse: {:?}", e))?; 31 33 32 - let mut ws = Workspace::with_std().map_err(|e| format!("Failed to create workspace: {:?}", e))?; 34 + let mut ws = 35 + Workspace::with_std().map_err(|e| format!("Failed to create workspace: {:?}", e))?; 33 36 ws.add_module(namespace.clone(), lexicon) 34 37 .map_err(|e| format!("Failed to add module: {:?}", e))?; 35 38 ws.resolve()
+5 -2
tests/diagnostics_integration.rs
··· 15 15 use std::path::Path; 16 16 17 17 fn run_diagnostics_test(input_path: &Path) -> datatest_stable::Result<()> { 18 - let test_dir = input_path.parent().ok_or("input.mlf has no parent directory")?; 18 + let test_dir = input_path 19 + .parent() 20 + .ok_or("input.mlf has no parent directory")?; 19 21 let test_name = test_dir 20 22 .file_name() 21 23 .and_then(|s| s.to_str()) ··· 33 35 let input = fs::read_to_string(input_path)?; 34 36 let lexicon = parse_lexicon(&input).map_err(|e| format!("Failed to parse: {:?}", e))?; 35 37 36 - let mut ws = Workspace::with_std().map_err(|e| format!("Failed to create workspace: {:?}", e))?; 38 + let mut ws = 39 + Workspace::with_std().map_err(|e| format!("Failed to create workspace: {:?}", e))?; 37 40 ws.add_module(namespace.clone(), lexicon) 38 41 .map_err(|e| format!("Failed to add module: {:?}", e))?; 39 42
+5 -1
tests/lexicon_fetcher_integration.rs
··· 85 85 .into()); 86 86 } 87 87 } 88 - } else if test_dns.resolve_lexicon_did(&authority, &dns_name).await.is_ok() { 88 + } else if test_dns 89 + .resolve_lexicon_did(&authority, &dns_name) 90 + .await 91 + .is_ok() 92 + { 89 93 return Err("Expected DNS lookup to fail, but it succeeded".into()); 90 94 } 91 95
+3 -1
tests/lexicon_to_mlf_integration.rs
··· 14 14 use std::path::Path; 15 15 16 16 fn run_case(input_path: &Path) -> datatest_stable::Result<()> { 17 - let test_dir = input_path.parent().ok_or("input.json has no parent directory")?; 17 + let test_dir = input_path 18 + .parent() 19 + .ok_or("input.json has no parent directory")?; 18 20 let test_name = test_dir 19 21 .file_name() 20 22 .and_then(|s| s.to_str())
+43 -21
tests/real_world/roundtrip.rs
··· 80 80 } 81 81 82 82 assert_eq!( 83 - stats.failures, 0, 83 + stats.failures, 84 + 0, 84 85 "Round-trip failed for {} lexicon(s) under {}. See {}", 85 86 stats.failures, 86 87 source, ··· 143 144 /// workspace, resolve it, and regenerate Lexicon JSON for each module. 144 145 /// Returns a map keyed by the on-disk path relative to `mlf_dir` (so 145 146 /// callers can line up against the original JSON tree). 146 - fn regenerate_lexicons_from_mlf(mlf_dir: &Path) -> Result<Vec<(PathBuf, serde_json::Value)>, String> { 147 - let mut ws = Workspace::with_std().map_err(|e| format!("Failed to create workspace: {:?}", e))?; 147 + fn regenerate_lexicons_from_mlf( 148 + mlf_dir: &Path, 149 + ) -> Result<Vec<(PathBuf, serde_json::Value)>, String> { 150 + let mut ws = 151 + Workspace::with_std().map_err(|e| format!("Failed to create workspace: {:?}", e))?; 148 152 load_mlf_directory(&mut ws, mlf_dir)?; 149 153 150 154 ws.resolve() ··· 231 235 let original_path = original_dir.join(relative_path); 232 236 if !original_path.exists() { 233 237 stats.failures += 1; 234 - stats 235 - .failed_lexicons 236 - .push((nsid, format!("Original file not found: {}", original_path.display()))); 238 + stats.failed_lexicons.push(( 239 + nsid, 240 + format!("Original file not found: {}", original_path.display()), 241 + )); 237 242 continue; 238 243 } 239 244 ··· 328 333 } 329 334 serde_json::Value::Object(sorted) 330 335 } 331 - serde_json::Value::Array(arr) => { 332 - serde_json::Value::Array( 333 - arr.iter() 334 - .map(|v| canonicalize_lexicon_value(v, lexicon_id)) 335 - .collect(), 336 - ) 337 - } 336 + serde_json::Value::Array(arr) => serde_json::Value::Array( 337 + arr.iter() 338 + .map(|v| canonicalize_lexicon_value(v, lexicon_id)) 339 + .collect(), 340 + ), 338 341 _ => value.clone(), 339 342 } 340 343 } ··· 391 394 392 395 // Strip empty `required: []` — ATProto treats absent and empty 393 396 // equivalently, and our converter drops them. 394 - if obj.get("required").and_then(|v| v.as_array()).map_or(false, |a| a.is_empty()) { 397 + if obj 398 + .get("required") 399 + .and_then(|v| v.as_array()) 400 + .map_or(false, |a| a.is_empty()) 401 + { 395 402 obj.remove("required"); 396 403 } 397 404 398 405 // Normalize missing `properties` on object types — our converter 399 406 // always emits `properties: {}` even when the original omits it. 400 - if obj.get("type").and_then(|v| v.as_str()) == Some("object") && !obj.contains_key("properties") { 407 + if obj.get("type").and_then(|v| v.as_str()) == Some("object") && !obj.contains_key("properties") 408 + { 401 409 obj.insert("properties".to_string(), serde_json::json!({})); 402 410 } 403 411 ··· 439 447 // description is style guidance, not structural. 440 448 if obj.get("type").and_then(|v| v.as_str()) == Some("array") { 441 449 if let Some(serde_json::Value::Object(items)) = obj.get_mut("items") { 442 - if items.get("type").and_then(|v| v.as_str()).map_or(false, |t| { 443 - matches!(t, "string" | "integer" | "boolean" | "bytes" | "blob" | "unknown") 444 - }) { 450 + if items 451 + .get("type") 452 + .and_then(|v| v.as_str()) 453 + .map_or(false, |t| { 454 + matches!( 455 + t, 456 + "string" | "integer" | "boolean" | "bytes" | "blob" | "unknown" 457 + ) 458 + }) 459 + { 445 460 items.remove("description"); 446 461 } 447 462 } ··· 465 480 fn canonicalize_ref_string(ref_str: &str, lexicon_id: &str) -> String { 466 481 let last_segment = lexicon_id.rsplit('.').next().unwrap_or(""); 467 482 if let Some(fragment) = ref_str.strip_prefix('#') { 468 - let canonical_fragment = if fragment == last_segment { "main" } else { fragment }; 483 + let canonical_fragment = if fragment == last_segment { 484 + "main" 485 + } else { 486 + fragment 487 + }; 469 488 format!("{}#{}", lexicon_id, canonical_fragment) 470 489 } else if let Some(pos) = ref_str.find('#') { 471 490 let ns = &ref_str[..pos]; 472 491 let fragment = &ref_str[pos + 1..]; 473 492 let ns_last = ns.rsplit('.').next().unwrap_or(""); 474 - let canonical_fragment = if fragment == ns_last { "main" } else { fragment }; 493 + let canonical_fragment = if fragment == ns_last { 494 + "main" 495 + } else { 496 + fragment 497 + }; 475 498 format!("{}#{}", ns, canonical_fragment) 476 499 } else { 477 500 format!("{}#main", ref_str) ··· 520 543 521 544 Ok(()) 522 545 } 523 -
+3 -9
tests/test_utils.rs
··· 30 30 31 31 if !config_path.exists() { 32 32 // Fallback: create default config using provided function 33 - let test_name = test_dir 34 - .file_name() 35 - .unwrap() 36 - .to_str() 37 - .unwrap() 38 - .to_string(); 33 + let test_name = test_dir.file_name().unwrap().to_str().unwrap().to_string(); 39 34 let namespace = default_namespace_fn(&test_name); 40 35 41 36 let mut modules = HashMap::new(); ··· 51 46 }); 52 47 } 53 48 54 - let config_str = fs::read_to_string(&config_path) 55 - .map_err(|e| format!("Failed to read test.toml: {}", e))?; 49 + let config_str = 50 + fs::read_to_string(&config_path).map_err(|e| format!("Failed to read test.toml: {}", e))?; 56 51 57 52 toml::from_str(&config_str).map_err(|e| format!("Failed to parse test.toml: {}", e)) 58 53 } 59 -
+3 -6
website/mlf-playground-wasm/src/lib.rs
··· 3 3 4 4 // Import the plugin crates and reference their static generators 5 5 // This forces the linker to include them in the binary 6 - use mlf_codegen_typescript::TYPESCRIPT_GENERATOR; 7 6 use mlf_codegen_go::GO_GENERATOR; 8 7 use mlf_codegen_rust::RUST_GENERATOR; 8 + use mlf_codegen_typescript::TYPESCRIPT_GENERATOR; 9 9 10 10 // Force the linker to keep the generator statics by referencing them 11 11 // This function must never be optimized away 12 12 #[used] 13 - static _KEEP_GENERATORS: &[&dyn mlf_codegen::plugin::CodeGenerator] = &[ 14 - &TYPESCRIPT_GENERATOR, 15 - &GO_GENERATOR, 16 - &RUST_GENERATOR, 17 - ]; 13 + static _KEEP_GENERATORS: &[&dyn mlf_codegen::plugin::CodeGenerator] = 14 + &[&TYPESCRIPT_GENERATOR, &GO_GENERATOR, &RUST_GENERATOR];