From 93a100a6cbcc15686fbdf09867c662dd5bfd1459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 31 Oct 2026 18:03:40 +1100 Subject: [PATCH 01/15] chore: update dependencies --- Cargo.lock | 293 +++++++++++++++++++++++++++++------------------------ Cargo.toml | 6 +- 2 files changed, 163 insertions(+), 136 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b184e99..7302f41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,9 +141,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -183,9 +183,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.4" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "shlex", @@ -193,9 +193,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -247,7 +247,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "crossterm_winapi", "derive_more", "document-features", @@ -308,7 +308,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] @@ -319,7 +319,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] @@ -330,9 +330,9 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" [[package]] name = "derive_more" @@ -388,15 +388,15 @@ dependencies = [ [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -440,15 +440,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "finl_unicode" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" [[package]] name = "fixedbitset" @@ -534,17 +534,6 @@ dependencies = [ "r-efi 6.0.0", ] -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -576,34 +565,34 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "indexmap" -version = "2.14.1" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.17.1", + "hashbrown", ] [[package]] name = "indoc" -version = "2.0.7" +version = "2.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" dependencies = [ "rustversion", ] [[package]] name = "instability" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" dependencies = [ "darling", "indoc", "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] @@ -623,9 +612,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" dependencies = [ "cfg-if", "futures-util", @@ -634,13 +623,13 @@ dependencies = [ [[package]] name = "kasuari" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209" dependencies = [ - "hashbrown 0.16.1", + "hashbrown", "portable-atomic", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -651,15 +640,15 @@ checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libm" @@ -673,7 +662,7 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -684,9 +673,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -699,27 +688,27 @@ dependencies = [ [[package]] name = "log" -version = "0.4.34" +version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" [[package]] name = "lru" -version = "0.18.3" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" dependencies = [ - "hashbrown 0.17.1", + "hashbrown", ] [[package]] name = "mac_address" -version = "1.1.8" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381" dependencies = [ - "nix", - "winapi", + "nix 0.31.3", + "windows-sys", ] [[package]] @@ -751,9 +740,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "log", @@ -767,7 +756,19 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -812,19 +813,13 @@ dependencies = [ [[package]] name = "num_threads" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" dependencies = [ "libc", ] -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - [[package]] name = "opaline" version = "0.4.2" @@ -834,7 +829,7 @@ dependencies = [ "parking_lot", "ratatui-core", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "toml", "unicode-segmentation", ] @@ -906,19 +901,20 @@ dependencies = [ [[package]] name = "pest" -version = "2.9.0" +version = "2.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +checksum = "a8db75153506001cb36ed924f08cadc862eb3f8c4d0d534139f29074769d7660" dependencies = [ "memchr", + "pest_stack_guard", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.9.0" +version = "2.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +checksum = "20aa17e11fb35664b67d5214a0afac57a360d231b6ec53d6eba0700b27d933b4" dependencies = [ "pest", "pest_generator", @@ -926,26 +922,36 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.9.0" +version = "2.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +checksum = "626ec5afccf44ccbfeb2b232316510bb55b4bf34d891afcb0fb07d2b17d7fe9d" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "pest_meta" -version = "2.9.0" +version = "2.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +checksum = "b3f45b6caa65c1b08186832745159c2529994146340a7bf44168095469720f5d" dependencies = [ "pest", ] +[[package]] +name = "pest_stack_guard" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97e6cd2217057fa86b6eaa512917e58089d875b957a31e35e7ab729977e0a5f2" +dependencies = [ + "psm", + "stacker", +] + [[package]] name = "phf" version = "0.11.3" @@ -1012,9 +1018,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "proc-macro2" @@ -1025,13 +1031,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + [[package]] name = "pulldown-cmark" -version = "0.13.4" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +checksum = "3db2be46835f013dd3f4fa7f5f7402c537787b54ebb6fe2b94bbdd7e2669bb7b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "memchr", "unicase", ] @@ -1094,17 +1109,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "compact_str", "critical-section", - "hashbrown 0.17.1", + "hashbrown", "itertools", "kasuari", "lru", "palette", "serde", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -1159,8 +1174,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.13.1", - "hashbrown 0.17.1", + "bitflags 2.13.2", + "hashbrown", "indoc", "instability", "itertools", @@ -1179,7 +1194,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -1248,11 +1263,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -1310,14 +1325,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -1385,9 +1400,9 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -1397,9 +1412,22 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.0" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "stacker" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys", +] [[package]] name = "static_assertions" @@ -1464,9 +1492,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -1479,7 +1507,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "parking_lot", "rustix", "signal-hook", @@ -1515,7 +1543,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.13.1", + "bitflags 2.13.2", "fancy-regex", "filedescriptor", "finl_unicode", @@ -1525,7 +1553,7 @@ dependencies = [ "libc", "log", "memmem", - "nix", + "nix 0.29.0", "num-derive", "num-traits", "ordered-float", @@ -1560,11 +1588,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -1580,13 +1608,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] @@ -1653,9 +1681,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tree-sitter-language" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" +checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a" [[package]] name = "typenum" @@ -1671,15 +1699,15 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-segmentation" @@ -1700,9 +1728,9 @@ dependencies = [ [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" [[package]] name = "utf8parse" @@ -1712,9 +1740,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.26.0" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "atomic", "getrandom 0.4.3", @@ -1754,12 +1782,11 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" dependencies = [ "cfg-if", - "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", @@ -1767,9 +1794,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1777,22 +1804,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" dependencies = [ "unicode-ident", ] diff --git a/Cargo.toml b/Cargo.toml index 81995de..089e8d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,9 @@ path = "src/main.rs" [dependencies] anyhow = "1" -arborium = { version = "2.18.1", features = ["lang-rust"] } -arborium-highlight = "2.18.1" -arborium-theme = "2.18.1" +arborium = { version = "2.18.2", features = ["lang-rust"] } +arborium-highlight = "2.18.2" +arborium-theme = "2.18.2" frizbee = "0.13" opaline = { version = "0.4.2", features = ["global-state"] } pulldown-cmark = { version = "0.13", default-features = false } From 2980418e36f2dc396f868945a318c2689ae4317f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 31 Oct 2026 18:18:06 +1100 Subject: [PATCH 02/15] fix: show items under the paths rustdoc publishes them at --- CLAUDE.md | 12 ++- src/docs.rs | 275 ++++++++++++++++++++++++++++++++++++++++-------- src/index.rs | 223 ++------------------------------------- src/load.rs | 9 +- src/main.rs | 6 +- src/testdocs.rs | 20 +--- 6 files changed, 259 insertions(+), 286 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 83d9ec8..fb8e86b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,10 +93,14 @@ Data flows in one direction: `load` → `docs` → `index` → `page` → `ui`. ### Three ideas worth knowing before editing **Display paths vs. canonical paths.** `Vec` really lives at `alloc::vec::Vec` -but must be *shown* as `std::vec::Vec`. `main` builds the index, then calls -`universe.set_display_paths(index.display_paths())` to install the reader-facing -spelling. Both spellings exist for most items, and lookups often have to try each -— `by_path` is keyed canonically, so a display path will not resolve against it. +but must be *shown* as `std::vec::Vec`. `Universe::new` derives the reader-facing +spelling the way rustdoc's HTML inlining does: it walks each crate's public +namespace (`std` first, so its spelling wins), following re-exports (named and +glob) across crates and skipping `#[doc(no_inline)]` ones, which the website +renders as links rather than pages. Anything not reachable that way — a method, +an internal — is spelled through its nearest ancestor that is. `path_of` returns it. +Both spellings exist for most items, and lookups often have to try each — +`by_path` is keyed canonically, so a display path will not resolve against it. **Not every item is in `paths`.** Trait-impl members (`String`'s `clone`) are absent from rustdoc's `paths` map entirely: no path, no kind, so `kind_of`, diff --git a/src/docs.rs b/src/docs.rs index f566e5e..9b9ceea 100644 --- a/src/docs.rs +++ b/src/docs.rs @@ -9,9 +9,13 @@ //! `Id` to its canonical path (`alloc::string::String`); that path is then //! looked up in the crate that actually owns it. -use std::collections::HashMap; +use std::collections::hash_map::Entry; +use std::collections::{HashMap, HashSet}; +use std::hash::Hash; -use rustdoc_types::{Crate, Id, Item, ItemEnum, ItemKind, ItemSummary, Type}; +use rustdoc_types::{ + Attribute, Crate, Id, Item, ItemEnum, ItemKind, ItemSummary, Type, Visibility, +}; /// Index of a crate within a [`Universe`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -34,17 +38,16 @@ impl ItemRef { pub struct Universe { crates: Vec, names: Vec, - /// Canonical path (e.g. `alloc::vec::Vec`) to the item that owns it. + /// Canonical path (e.g. `alloc::vec::Vec`) to every item claiming it. /// /// Rust's namespaces are separate, so a path can name more than one item — - /// `alloc::vec` is both the `vec!` macro and the `vec` module. This map - /// keeps whichever was seen last, which is fine for resolving a link (the - /// two are interchangeable as a destination) but not for picking a parent; - /// see [`Universe::container_at`]. - by_path: HashMap, - /// Every item claiming a given canonical path, for the collision above. - all_by_path: HashMap>, - /// Preferred display path per item, when it differs from the canonical one. + /// `alloc::vec` is both the `vec!` macro and the `vec` module. Resolving a + /// link takes whichever was seen last, since the two are interchangeable + /// as a destination, but picking a parent cannot; see + /// [`Universe::container_at`]. + by_path: HashMap>, + /// The `std::` spelling of each item, where it differs from the canonical + /// one: `alloc::vec::Vec` is shown as `std::vec::Vec`. display: HashMap, /// The type each associated item is declared on, via the impl listing it. /// @@ -55,22 +58,24 @@ pub struct Universe { } impl Universe { - /// Build a universe from parsed crates and their names, in matching order. - pub fn new(crates: Vec, names: Vec) -> Self { - assert_eq!(crates.len(), names.len(), "crate/name count mismatch"); + /// Build a universe from parsed crates, each paired with its name. + /// + /// The first crate is the one the app opens on; see [`Universe::root`]. + pub fn new(crates: Vec<(String, Crate)>) -> Self { + let (names, crates): (Vec, Vec) = crates.into_iter().unzip(); // Map each canonical path to the crate that owns it. `crate_id == 0` // means "defined here", so this never records a foreign re-export. - let mut by_path = HashMap::new(); - let mut all_by_path: HashMap> = HashMap::new(); + let mut by_path: HashMap> = HashMap::new(); for (i, krate) in crates.iter().enumerate() { let cid = CrateId(i); for (id, summary) in &krate.paths { if summary.crate_id == 0 { let path = summary.path.join("::"); - let r = ItemRef::new(cid, *id); - by_path.insert(path.clone(), r); - all_by_path.entry(path).or_default().push(r); + by_path + .entry(path) + .or_default() + .push(ItemRef::new(cid, *id)); } } } @@ -94,14 +99,135 @@ impl Universe { } } - Self { + let mut universe = Self { crates, names, by_path, - all_by_path, display: HashMap::new(), owner, + }; + universe.display = universe.display_paths(); + universe + } + + /// The public spelling of every item that has one other than its + /// canonical path. + /// + /// Readers know `Vec` as `std::vec::Vec`, not `alloc::vec::Vec`, so this + /// is the name search shows and pages are titled with. Items take the path + /// the first crate to expose them gives them — `std` where it does, their + /// own crate's public path where `std` only points at them; anything else + /// — a method, an internal — is spelled through its nearest ancestor that + /// has one. + fn display_paths(&self) -> HashMap { + let mut reachable = HashMap::new(); + for cid in self.crate_ids() { + for (r, shown) in self.namespace(cid) { + reachable.entry(r).or_insert(shown); + } } + let mut ancestors = HashMap::new(); + for (r, shown) in &reachable { + if let Some(s) = self.summary(*r) { + prefer(&mut ancestors, s.path.join("::"), shown.clone()); + } + } + + let mut out = HashMap::new(); + for cid in self.crate_ids() { + for (id, summary) in &self.krate(cid).paths { + if summary.crate_id != 0 { + continue; + } + let r = ItemRef::new(cid, *id); + let canonical = summary.path.join("::"); + let shown = match reachable.get(&r) { + Some(shown) => Some(shown.clone()), + None => beneath(&canonical, &ancestors), + }; + if let Some(shown) = shown + && shown != canonical + { + out.insert(r, shown); + } + } + } + out + } + + /// Every item reachable through a crate's public modules, under the + /// shortest path that reaches it. + /// + /// Re-exports are followed through every crate, because that is how the + /// names are built: `Formatter` is defined at `core::fmt::Formatter`, + /// re-exported by `alloc::fmt`, which `std` re-exports as `std::fmt`. + /// Following them rather than mapping module names is also what keeps + /// this right as `core` and `alloc` reorganise their internals. + fn namespace(&self, krate: CrateId) -> HashMap { + let mut shown = HashMap::new(); + let root = ItemRef::new(krate, self.krate(krate).root); + let mut stack = vec![(root, self.crate_name(krate).to_string())]; + let mut visited = HashSet::new(); + + while let Some((module, path)) = stack.pop() { + if !visited.insert((module, path.clone())) { + continue; + } + let Some(ItemEnum::Module(m)) = self.item(module).map(|i| &i.inner) else { + continue; + }; + for id in &m.items { + let Some(child) = self.item(ItemRef::new(module.krate, *id)) else { + continue; + }; + if matches!( + child.visibility, + Visibility::Crate | Visibility::Restricted { .. } + ) { + continue; + } + let (target, at) = match &child.inner { + // `#[doc(no_inline)]` is rustdoc's own word that a + // re-export is a pointer, not a home: the preludes are + // written this way, and the website gives such an item + // no page there. + ItemEnum::Use(_) if is_no_inline(child) => continue, + ItemEnum::Use(u) => { + let Some(target) = u.id.and_then(|id| self.resolve(module.krate, id)) + else { + continue; + }; + // A glob pours the target's contents into this module: + // `pub use alloc_crate::alloc::*` is what fills + // `std::alloc`, so that is also the name the globbed + // module goes by. (An enum's variants can be globbed + // too, and the enum is not renamed by it.) + if u.is_glob { + if self.is_module(target) { + prefer(&mut shown, target, path.clone()); + } + stack.push((target, path.clone())); + continue; + } + (target, format!("{path}::{}", u.name)) + } + _ => { + let Some(name) = &child.name else { continue }; + (ItemRef::new(module.krate, *id), format!("{path}::{name}")) + } + }; + // Only a module reached by a better path is worth walking + // again, which is also what stops re-export cycles. + if prefer(&mut shown, target, at.clone()) && self.is_module(target) { + stack.push((target, at)); + } + } + } + shown + } + + fn is_module(&self, r: ItemRef) -> bool { + matches!(self.item(r).map(|i| &i.inner), Some(ItemEnum::Module(_))) } /// The type an associated item is declared on, if it is one. @@ -109,12 +235,6 @@ impl Universe { self.owner.get(&r).copied() } - /// Record the user-facing spelling for items whose canonical path differs - /// (`alloc::vec::Vec` is shown as `std::vec::Vec`). - pub fn set_display_paths(&mut self, display: HashMap) { - self.display = display; - } - pub fn crate_name(&self, krate: CrateId) -> &str { &self.names[krate.0] } @@ -149,8 +269,9 @@ impl Universe { } /// Look an item up by its canonical path. + #[cfg(test)] pub fn by_path(&self, path: &str) -> Option { - self.by_path.get(path).copied() + self.by_path.get(path)?.last().copied() } /// Resolve an `Id` encountered while reading crate `from`. @@ -163,14 +284,21 @@ impl Universe { if krate.index.contains_key(&id) { return Some(ItemRef::new(from, id)); } + // The foreign path can name items in several namespaces (`alloc::vec` + // is a macro and a module); the summary's kind says which was meant. let summary = krate.paths.get(&id)?; - self.by_path(&summary.path.join("::")) + let candidates = self.by_path.get(&summary.path.join("::"))?; + candidates + .iter() + .find(|r| self.kind_of(**r) == Some(summary.kind)) + .or(candidates.last()) + .copied() } - /// The canonical path for a reference, as a `::`-joined string. + /// The path to show for a reference: `std`'s spelling where it has one, + /// the canonical path otherwise. /// - /// Falls back to walking up the module tree when `paths` has no entry, - /// which is common for associated items. + /// `None` for items absent from `paths`, such as trait-impl members. pub fn path_of(&self, r: ItemRef) -> Option { if let Some(p) = self.display.get(&r) { return Some(p.clone()); @@ -224,7 +352,7 @@ impl Universe { /// nothing, so prefer a module or a type and fall back to whatever is /// there for paths with no collision. fn container_at(&self, path: &str) -> Option { - let candidates = self.all_by_path.get(path)?; + let candidates = self.by_path.get(path)?; candidates .iter() .find(|r| self.kind_of(**r).is_some_and(is_container)) @@ -233,6 +361,44 @@ impl Universe { } } +/// Spell a canonical path through its deepest ancestor that has a display +/// path: methods of `core::option::Option` sit under `std::option::Option`. +fn beneath(canonical: &str, ancestors: &HashMap) -> Option { + canonical.rmatch_indices("::").find_map(|(i, _)| { + let shown = ancestors.get(&canonical[..i])?; + Some(format!("{shown}{}", &canonical[i..])) + }) +} + +/// Record `path` for `key` unless a shorter spelling is already there, and +/// report whether it was recorded. +/// +/// An item can be exposed in more than one place — `HashMap` has a page at +/// `std::collections` and at `std::collections::hash_map` — and the shorter +/// is the one people write. +fn prefer(map: &mut HashMap, key: K, path: String) -> bool { + match map.entry(key) { + Entry::Occupied(mut e) => { + let better = path.len() < e.get().len(); + if better { + e.insert(path); + } + better + } + Entry::Vacant(e) => { + e.insert(path); + true + } + } +} + +/// Whether a re-export is marked `#[doc(no_inline)]`. +fn is_no_inline(item: &Item) -> bool { + item.attrs + .iter() + .any(|a| matches!(a, Attribute::Other(s) if s == "#[doc(no_inline)]")) +} + /// The id of the type an impl is written for, when it names one. fn impl_self_id(t: &Type) -> Option { match t { @@ -273,28 +439,49 @@ mod tests { fn resolves_reexports_across_crates() { let u = universe(); - // These live in `alloc`/`core` but are reached through `std`. - for path in [ - "alloc::string::String", - "alloc::vec::Vec", - "core::option::Option", + // These live in `alloc`/`core` but are reached, and shown, through `std`. + for (path, shown) in [ + ("alloc::string::String", "std::string::String"), + ("alloc::vec::Vec", "std::vec::Vec"), + ("core::option::Option", "std::option::Option"), ] { let r = u .by_path(path) .unwrap_or_else(|| panic!("{path} not found")); assert!(u.item(r).is_some(), "{path} resolved to a missing item"); - assert_eq!(u.path_of(r).as_deref(), Some(path)); + assert_eq!(u.path_of(r).as_deref(), Some(shown)); } } + /// What std does not expose by name is spelled through the nearest + /// ancestor it does, however deep that ancestor sits in its own crate. + #[test] + fn items_take_the_std_name_of_their_nearest_exposed_ancestor() { + let ancestors = HashMap::from([ + ("alloc::rcs::rc".to_string(), "std::rc".to_string()), + ( + "core::option::Option".to_string(), + "std::option::Option".to_string(), + ), + ]); + assert_eq!( + beneath("alloc::rcs::rc::Rc", &ancestors).as_deref(), + Some("std::rc::Rc") + ); + assert_eq!( + beneath("core::option::Option::map", &ancestors).as_deref(), + Some("std::option::Option::map") + ); + // Nothing above it is exposed, so there is nothing to spell it with. + assert_eq!(beneath("core::core_arch::x86::foo", &ancestors), None); + } + /// `u` walks up the chain a reader actually sees: method -> type -> /// module -> ... -> crate root, in the `std::` spelling wherever std has /// one. #[test] fn parent_walks_up_to_the_crate_root() { - let mut u = universe(); - let idx = crate::index::SearchIndex::build(&u); - u.set_display_paths(idx.display_paths()); + let u = universe(); let start = u .by_path("alloc::string::String::push_str") @@ -313,9 +500,7 @@ mod tests { /// since `std::vec` is not itself a key in `by_path`. #[test] fn parent_of_a_reexported_type_finds_its_module() { - let mut u = universe(); - let idx = crate::index::SearchIndex::build(&u); - u.set_display_paths(idx.display_paths()); + let u = universe(); let vec = u.by_path("alloc::vec::Vec").expect("Vec"); let parent = u.parent_of(vec).expect("Vec has a parent module"); diff --git a/src/index.rs b/src/index.rs index 897dbb1..8d23276 100644 --- a/src/index.rs +++ b/src/index.rs @@ -1,11 +1,9 @@ //! The flat, searchable index over the whole universe. -use std::collections::HashMap; - use frizbee::{CaseMatching, Config, Matcher}; -use rustdoc_types::{ItemEnum, ItemKind}; +use rustdoc_types::ItemKind; -use crate::docs::{CrateId, ItemRef, Universe}; +use crate::docs::{ItemRef, Universe}; /// One searchable entry: an item, and the path we show for it. pub struct Entry { @@ -31,8 +29,6 @@ pub struct SearchIndex { impl SearchIndex { pub fn build(universe: &Universe) -> Self { - let aliases = std_aliases(universe); - let mut entries: Vec = Vec::new(); for cid in universe.crate_ids() { let krate = universe.krate(cid); @@ -42,22 +38,15 @@ impl SearchIndex { if summary.crate_id != 0 { continue; } - let canonical = summary.path.join("::"); let Some(name) = summary.path.last().cloned() else { continue; }; - // Prefer the familiar `std::` spelling when std re-exports it. - // Both sources can name the same item; pick the better spelling. - let path = [ - aliases.get(&canonical).cloned(), - std_facing_path(&canonical), - ] - .into_iter() - .flatten() - .min_by_key(|p| alias_preference(p)) - .unwrap_or(canonical); + let item = ItemRef::new(cid, *id); + let Some(path) = universe.path_of(item) else { + continue; + }; entries.push(Entry { - item: ItemRef::new(cid, *id), + item, path, name, kind: summary.kind, @@ -90,14 +79,6 @@ impl SearchIndex { self.entries.len() } - /// The display path chosen for each item, for the universe to reuse. - pub fn display_paths(&self) -> HashMap { - self.entries - .iter() - .map(|e| (e.item, e.path.clone())) - .collect() - } - /// Fuzzy-search the index, best matches first. /// /// A query containing `::` is matched against full paths; otherwise it is @@ -269,173 +250,13 @@ fn rank(e: &Entry) -> u8 { depth_penalty * 4 + kind_penalty } -/// Map canonical paths to the `std::` path that re-exports them. -/// -/// Walks `std`'s `Use` items; each names a target whose id is crate-local to -/// `std` but whose `paths` entry gives the canonical foreign path. -fn std_aliases(universe: &Universe) -> HashMap { - let mut aliases: HashMap = HashMap::new(); - - let Some(std_id) = universe - .crate_ids() - .find(|c| universe.crate_name(*c) == "std") - else { - return aliases; - }; - let krate = universe.krate(std_id); - - // Module paths within std, so a re-export can be given its full std path. - let module_path = std_module_paths(universe, std_id); - - for (id, item) in &krate.index { - let ItemEnum::Use(u) = &item.inner else { - continue; - }; - if u.is_glob { - continue; - } - let Some(target) = u.id else { continue }; - // The target id is meaningful only inside std; `paths` translates it. - let Some(summary) = krate.paths.get(&target) else { - continue; - }; - let canonical = summary.path.join("::"); - - let Some(parent) = module_path.get(id) else { - continue; - }; - let alias = if parent.is_empty() { - format!("std::{}", u.name) - } else { - format!("std::{}::{}", parent, u.name) - }; - - // Prefer the canonical home (`std::vec::Vec`) over re-export shims - // like `std::prelude::v1::Vec`, then prefer the shorter path. - aliases - .entry(canonical) - .and_modify(|existing| { - if alias_preference(&alias) < alias_preference(existing) { - *existing = alias.clone(); - } - }) - .or_insert(alias); - } - - aliases -} - -/// Rewrite a `core::`/`alloc::` path to the `std::` path that mirrors it. -/// -/// `std` re-exports whole modules of `core` and `alloc` with `pub use`, but -/// rustdoc's JSON records those as glob re-exports whose module trees are not -/// reachable from `std`'s root — so the mapping cannot be derived structurally -/// and is listed here instead. These module names are long-standing stable -/// public API, so the list is stable too. -fn std_facing_path(canonical: &str) -> Option { - const MIRRORED: &[&str] = &[ - "alloc", - "any", - "array", - "ascii", - "borrow", - "boxed", - "cell", - "char", - "clone", - "cmp", - "convert", - "default", - "error", - "f32", - "f64", - "ffi", - "fmt", - "future", - "hash", - "hint", - "isize", - "iter", - "marker", - "mem", - "num", - "ops", - "option", - "panic", - "pin", - "primitive", - "ptr", - "rc", - "result", - "slice", - "str", - "string", - "sync", - "task", - "time", - "usize", - "vec", - ]; - - let (krate, rest) = canonical.split_once("::")?; - if krate != "core" && krate != "alloc" { - return None; - } - let module = rest.split("::").next()?; - if MIRRORED.contains(&module) { - Some(format!("std::{rest}")) - } else { - None - } -} - -/// Sort key deciding which of several std spellings to display. -/// -/// `std::prelude::*` and `std::simd::prelude::*` re-export half the library -/// under a shorter path, so path length alone picks the wrong name. -fn alias_preference(path: &str) -> (u8, usize) { - let bucket = if path.contains("::prelude::") { 1 } else { 0 }; - (bucket, path.len()) -} - -/// For each item in std, the `::`-joined module path containing it (minus `std::`). -fn std_module_paths(universe: &Universe, std_id: CrateId) -> HashMap { - let krate = universe.krate(std_id); - let mut out = HashMap::new(); - let mut stack = vec![(krate.root, String::new())]; - - while let Some((mod_id, prefix)) = stack.pop() { - let Some(item) = krate.index.get(&mod_id) else { - continue; - }; - let ItemEnum::Module(m) = &item.inner else { - continue; - }; - - for child in &m.items { - out.insert(*child, prefix.clone()); - if let Some(c) = krate.index.get(child) - && matches!(c.inner, ItemEnum::Module(_)) - && let Some(name) = &c.name - { - let next = if prefix.is_empty() { - name.clone() - } else { - format!("{prefix}::{name}") - }; - stack.push((*child, next)); - } - } - } - out -} - #[cfg(test)] mod tests { use super::*; + use crate::docs::CrateId; fn index() -> (Universe, SearchIndex) { - crate::testdocs::indexed_raw() + crate::testdocs::indexed() } #[test] @@ -478,7 +299,7 @@ mod tests { ("pathbuf", "std::path::PathBuf"), ("refcell", "std::cell::RefCell"), ("duration", "std::time::Duration"), - ("iterator", "std::iter::traits::iterator::Iterator"), + ("iterator", "std::iter::Iterator"), ] { let hits = idx.search(query, 5); assert!(!hits.is_empty(), "no hits for {query}"); @@ -518,28 +339,4 @@ mod tests { > obscurity_penalty(&e("std::sys::Vec", "Vec")) ); } - - #[test] - fn mirrors_core_and_alloc_modules_onto_std() { - assert_eq!( - std_facing_path("alloc::vec::Vec").as_deref(), - Some("std::vec::Vec") - ); - assert_eq!( - std_facing_path("core::option::Option").as_deref(), - Some("std::option::Option") - ); - assert_eq!( - std_facing_path("alloc::rc::Rc").as_deref(), - Some("std::rc::Rc") - ); - // Internals that std does not re-export keep their own name. - assert_eq!(std_facing_path("core::core_arch::x86::foo"), None); - assert_eq!(std_facing_path("std::fs::File"), None); - } - - #[test] - fn prefers_canonical_module_over_prelude() { - assert!(alias_preference("std::vec::Vec") < alias_preference("std::prelude::v1::Vec")); - } } diff --git a/src/load.rs b/src/load.rs index 3065680..4f97231 100644 --- a/src/load.rs +++ b/src/load.rs @@ -14,7 +14,7 @@ use rustdoc_types::Crate; /// /// `std` alone is not enough: `String` and `Vec` live in `alloc`, `Option` in /// `core`, and `std` merely re-exports them. -pub const STD_CRATES: [&str; 3] = ["std", "core", "alloc"]; +const STD_CRATES: [&str; 3] = ["std", "core", "alloc"]; /// The toolchain whose rustdoc JSON we read, overridable for when the current /// nightly has drifted past the format version we support. @@ -113,14 +113,15 @@ fn extract_format_version(text: &str) -> Option { /// Load and parse `std`, `core`, and `alloc` concurrently. /// -/// Returns them in the order given by [`STD_CRATES`]. -pub fn load_std_crates() -> Result> { +/// Returns each with its name, in the order given by [`STD_CRATES`], so `std` +/// comes first. +pub fn load_std_crates() -> Result> { let dir = json_dir()?; std::thread::scope(|scope| { let handles: Vec<_> = STD_CRATES .iter() - .map(|name| scope.spawn(|| load_one(&dir, name))) + .map(|name| scope.spawn(|| Ok((name.to_string(), load_one(&dir, name)?)))) .collect(); handles diff --git a/src/main.rs b/src/main.rs index b509cb0..de01fae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,12 +25,8 @@ fn main() -> Result<()> { // Load before touching the terminal, so errors print normally. eprint!("loading rust documentation... "); let started = std::time::Instant::now(); - let crates = load::load_std_crates()?; - let names = load::STD_CRATES.iter().map(|s| s.to_string()).collect(); - let mut universe = docs::Universe::new(crates, names); + let universe = docs::Universe::new(load::load_std_crates()?); let index = index::SearchIndex::build(&universe); - // Show items under the name people searched for, not their defining crate. - universe.set_display_paths(index.display_paths()); eprintln!( "{} items in {:.2}s", index.len(), diff --git a/src/testdocs.rs b/src/testdocs.rs index 1f02d9e..b80a731 100644 --- a/src/testdocs.rs +++ b/src/testdocs.rs @@ -14,27 +14,17 @@ use crate::load; /// /// Panics with the install hint when the rustdoc JSON is unavailable. pub fn universe() -> Universe { - let crates = match load::load_std_crates() { - Ok(crates) => crates, + match load::load_std_crates() { + Ok(crates) => Universe::new(crates), Err(e) => { panic!("these tests need the rustdoc JSON for std, which could not be loaded:\n{e:#}") } - }; - let names = load::STD_CRATES.iter().map(|s| s.to_string()).collect(); - Universe::new(crates, names) + } } -/// A universe and its search index, without display paths applied. -pub fn indexed_raw() -> (Universe, SearchIndex) { - let u = universe(); - let idx = SearchIndex::build(&u); - (u, idx) -} - -/// A universe with its search index, and display paths already applied. +/// A universe and its search index. pub fn indexed() -> (Universe, SearchIndex) { - let mut u = universe(); + let u = universe(); let idx = SearchIndex::build(&u); - u.set_display_paths(idx.display_paths()); (u, idx) } From 41d7ef23c656d273537aff85a852720a8962b0d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 31 Oct 2026 18:59:17 +1100 Subject: [PATCH 03/15] refactor: name and classify item kinds in one place --- CLAUDE.md | 2 ++ src/app.rs | 7 ++--- src/docs.rs | 17 ++--------- src/kind.rs | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/page.rs | 81 ++++++++------------------------------------------ src/ui.rs | 25 ++-------------- 7 files changed, 108 insertions(+), 110 deletions(-) create mode 100644 src/kind.rs diff --git a/CLAUDE.md b/CLAUDE.md index fb8e86b..6baf70d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,8 @@ Data flows in one direction: `load` → `docs` → `index` → `page` → `ui`. and owns all cross-crate identity. Items are addressed by `ItemRef` (crate + id), never by bare `Id`, because ids are only meaningful within their crate; `resolve` translates a foreign id through its canonical path. +- **`kind.rs`** names each `ItemKind` (page label, search badge) and says what + it can hold; add a kind's spelling there rather than matching on it locally. - **`index.rs`** — `SearchIndex` is the flat fuzzy-searchable view (frizbee). - **`page.rs`** builds a `Page`: styled lines plus `Target`s and foldable sections. - **`render.rs`** turns rustdoc markdown into styled lines and syntax-highlights diff --git a/src/app.rs b/src/app.rs index 3bc07ff..40b4a51 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,7 @@ use crate::docs::{ItemRef, Universe}; use crate::index::SearchIndex; +use crate::kind; use crate::page::{self, Page, SectionId, Target}; use crate::render::Highlighter; @@ -174,11 +175,7 @@ impl App { Some(K::Function | K::AssocConst | K::AssocType | K::StructField) => { let owner = self.universe.parent_of(item)?; // A module parent means a free function, which keeps its page. - matches!( - self.universe.kind_of(owner)?, - K::Struct | K::Enum | K::Union | K::Trait | K::Primitive - ) - .then_some(owner)? + kind::has_members(self.universe.kind_of(owner)?).then_some(owner)? } // Not in `paths` at all: only an impl can account for it. None => self.universe.owner_of(item)?, diff --git a/src/docs.rs b/src/docs.rs index 9b9ceea..e62f52c 100644 --- a/src/docs.rs +++ b/src/docs.rs @@ -17,6 +17,8 @@ use rustdoc_types::{ Attribute, Crate, Id, Item, ItemEnum, ItemKind, ItemSummary, Type, Visibility, }; +use crate::kind; + /// Index of a crate within a [`Universe`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct CrateId(pub usize); @@ -355,7 +357,7 @@ impl Universe { let candidates = self.by_path.get(path)?; candidates .iter() - .find(|r| self.kind_of(**r).is_some_and(is_container)) + .find(|r| self.kind_of(**r).is_some_and(kind::is_container)) .or_else(|| candidates.first()) .copied() } @@ -409,19 +411,6 @@ fn impl_self_id(t: &Type) -> Option { } } -/// Whether a kind can hold other items, and so can serve as a parent. -fn is_container(kind: ItemKind) -> bool { - matches!( - kind, - ItemKind::Module - | ItemKind::Struct - | ItemKind::Enum - | ItemKind::Trait - | ItemKind::Union - | ItemKind::Primitive - ) -} - /// Drop the last `::` segment, if there is one to drop. fn parent_path(path: &str) -> Option { let (parent, _) = path.rsplit_once("::")?; diff --git a/src/kind.rs b/src/kind.rs new file mode 100644 index 0000000..2f072b8 --- /dev/null +++ b/src/kind.rs @@ -0,0 +1,85 @@ +//! What the app makes of each [`ItemKind`]: how it is named, and what it can hold. +//! +//! Every screen that names or groups items reads it from here, so a kind is +//! spelled the same way on the page header, the module listing, and the search +//! results. A kind comes either from rustdoc's `paths` ([`Universe::kind_of`]) +//! or from an item in hand ([`ItemEnum::item_kind`]); the two agree, but only +//! the second covers items missing from `paths`. +//! +//! [`Universe::kind_of`]: crate::docs::Universe::kind_of +//! [`ItemEnum::item_kind`]: rustdoc_types::ItemEnum::item_kind + +use rustdoc_types::ItemKind; + +/// The kind as a page names it: "Struct", "Type Alias". +pub fn label(k: ItemKind) -> &'static str { + match k { + ItemKind::Module => "Module", + ItemKind::Struct => "Struct", + ItemKind::StructField => "Field", + ItemKind::Union => "Union", + ItemKind::Enum => "Enum", + ItemKind::Variant => "Variant", + ItemKind::Function => "Function", + ItemKind::Trait => "Trait", + ItemKind::TraitAlias => "Trait Alias", + ItemKind::Impl => "Impl", + ItemKind::TypeAlias => "Type Alias", + ItemKind::Constant => "Constant", + ItemKind::Static => "Static", + ItemKind::Macro => "Macro", + ItemKind::ProcAttribute => "Attribute Macro", + ItemKind::ProcDerive => "Derive Macro", + ItemKind::AssocConst => "Assoc Const", + ItemKind::AssocType => "Assoc Type", + ItemKind::Primitive => "Primitive", + ItemKind::Keyword => "Keyword", + ItemKind::ExternCrate => "Extern Crate", + ItemKind::Use => "Use", + ItemKind::ExternType => "Extern Type", + ItemKind::Attribute => "Attribute", + } +} + +/// The kind as a search result tags it: the keyword that declares it, where +/// there is one, since that is what a reader scans a column of results for. +pub fn badge(k: ItemKind) -> &'static str { + match k { + ItemKind::Module => "mod", + ItemKind::Struct => "struct", + ItemKind::StructField => "field", + ItemKind::Union => "union", + ItemKind::Enum => "enum", + ItemKind::Variant => "variant", + ItemKind::Function => "fn", + ItemKind::Trait | ItemKind::TraitAlias => "trait", + ItemKind::Impl => "impl", + ItemKind::TypeAlias | ItemKind::AssocType | ItemKind::ExternType => "type", + ItemKind::Constant | ItemKind::AssocConst => "const", + ItemKind::Static => "static", + ItemKind::Macro => "macro", + ItemKind::ProcAttribute | ItemKind::Attribute => "attr", + ItemKind::ProcDerive => "derive", + ItemKind::Primitive => "prim", + ItemKind::Keyword => "keyword", + ItemKind::ExternCrate => "crate", + ItemKind::Use => "use", + } +} + +/// Whether items of this kind carry methods and other associated items. +/// +/// These are the kinds a member is shown on rather than given a page of its +/// own; a module is not among them, which is what keeps `mem::swap` a page +/// while `String::push_str` is a row on `String`'s. +pub fn has_members(k: ItemKind) -> bool { + matches!( + k, + ItemKind::Struct | ItemKind::Enum | ItemKind::Union | ItemKind::Trait | ItemKind::Primitive + ) +} + +/// Whether a kind can hold other items, and so can serve as a parent. +pub fn is_container(k: ItemKind) -> bool { + k == ItemKind::Module || has_members(k) +} diff --git a/src/main.rs b/src/main.rs index de01fae..d7d07df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod app; mod docs; mod format; mod index; +mod kind; mod load; mod page; mod render; diff --git a/src/page.rs b/src/page.rs index 576a6cb..dd2e676 100644 --- a/src/page.rs +++ b/src/page.rs @@ -8,6 +8,7 @@ use rustdoc_types::{Id, Impl, Item, ItemEnum, ItemKind, StructKind, VariantKind, use crate::docs::{ItemRef, Universe}; use crate::format; +use crate::kind; use crate::render::{self, Highlighter}; use crate::theme; @@ -142,17 +143,16 @@ mod indent { } /// Module children are listed in this order, matching the docs.rs layout. -/// The first element is the kind name from [`inner_name`]. -const MODULE_KINDS: [(&str, &str); 9] = [ - ("Module", "Modules"), - ("Macro", "Macros"), - ("Struct", "Structs"), - ("Enum", "Enums"), - ("Trait", "Traits"), - ("Function", "Functions"), - ("Type Alias", "Type Aliases"), - ("Constant", "Constants"), - ("Primitive", "Primitives"), +const MODULE_KINDS: [(ItemKind, &str); 9] = [ + (ItemKind::Module, "Modules"), + (ItemKind::Macro, "Macros"), + (ItemKind::Struct, "Structs"), + (ItemKind::Enum, "Enums"), + (ItemKind::Trait, "Traits"), + (ItemKind::Function, "Functions"), + (ItemKind::TypeAlias, "Type Aliases"), + (ItemKind::Constant, "Constants"), + (ItemKind::Primitive, "Primitives"), ]; /// Collect and group the impls attached to an item. @@ -262,7 +262,6 @@ pub fn build( }; }; - let kind = u.kind_of(r); let path = u .path_of(r) .unwrap_or_else(|| item.name.clone().unwrap_or_else(|| "?".into())); @@ -271,10 +270,7 @@ pub fn build( // The kind and path identify the page rather than belonging to its body, // so they are handed to the item view as a fixed header line instead of // scrolling away with the first screenful. - let kind_label = kind - .as_ref() - .map(kind_name) - .unwrap_or_else(|| inner_name(&item.inner)); + let kind_label = kind::label(item.inner.item_kind()); if let Some(dep) = &item.deprecation { let mut s = String::from("Deprecated"); @@ -504,7 +500,7 @@ impl Builder<'_> { } let slot = MODULE_KINDS .iter() - .position(|(k, _)| *k == inner_name(&resolved.inner)); + .position(|(k, _)| *k == resolved.inner.item_kind()); if let Some(i) = slot && !groups[i].1.iter().any(|e| { self.u.item(*e).and_then(|i| i.name.as_ref()) == resolved.name.as_ref() @@ -674,35 +670,6 @@ fn hl_code(code: &str, hl: &Highlighter) -> Vec> { lines } -fn kind_name(k: &ItemKind) -> &'static str { - match k { - ItemKind::Module => "Module", - ItemKind::Struct => "Struct", - ItemKind::StructField => "Field", - ItemKind::Union => "Union", - ItemKind::Enum => "Enum", - ItemKind::Variant => "Variant", - ItemKind::Function => "Function", - ItemKind::Trait => "Trait", - ItemKind::TraitAlias => "Trait Alias", - ItemKind::Impl => "Impl", - ItemKind::TypeAlias => "Type Alias", - ItemKind::Constant => "Constant", - ItemKind::Static => "Static", - ItemKind::Macro => "Macro", - ItemKind::ProcAttribute => "Attribute Macro", - ItemKind::ProcDerive => "Derive Macro", - ItemKind::AssocConst => "Assoc Const", - ItemKind::AssocType => "Assoc Type", - ItemKind::Primitive => "Primitive", - ItemKind::Keyword => "Keyword", - ItemKind::ExternCrate => "Extern Crate", - ItemKind::Use => "Use", - ItemKind::ExternType => "Extern Type", - ItemKind::Attribute => "Attribute", - } -} - /// Whether an item belongs in a module listing. /// /// The JSON carries crate-internal items (`std::sys`, `std::panicking`) @@ -763,28 +730,6 @@ fn strip_link_brackets(s: &str) -> String { out } -fn inner_name(inner: &ItemEnum) -> &'static str { - match inner { - ItemEnum::Module(_) => "Module", - ItemEnum::Struct(_) => "Struct", - ItemEnum::Union(_) => "Union", - ItemEnum::Enum(_) => "Enum", - ItemEnum::Function(_) => "Function", - ItemEnum::Trait(_) => "Trait", - ItemEnum::Impl(_) => "Impl", - ItemEnum::TypeAlias(_) => "Type Alias", - ItemEnum::Constant { .. } => "Constant", - ItemEnum::Static(_) => "Static", - ItemEnum::Macro(_) => "Macro", - ItemEnum::Primitive(_) => "Primitive", - ItemEnum::Variant(_) => "Variant", - ItemEnum::StructField(_) => "Field", - ItemEnum::AssocConst { .. } => "Assoc Const", - ItemEnum::AssocType { .. } => "Assoc Type", - _ => "Item", - } -} - /// The sections a fresh page has been explicitly toggled open or shut. /// /// Empty: every section starts at its own default. Which sections a page even diff --git a/src/ui.rs b/src/ui.rs index 40342ea..ea688a9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,6 +7,7 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use crate::app::{App, Screen}; use crate::index::Entry; +use crate::kind; use crate::theme; pub fn draw(f: &mut Frame, app: &mut App) { @@ -84,7 +85,7 @@ fn result_line(e: &Entry, selected: bool, width: u16) -> Line<'static> { (" ", theme::unselected_name()) }; - let kind = kind_badge(&e.kind); + let kind = kind::badge(e.kind); // Show the module path dimmed, with the name itself highlighted. let (parent, name) = match e.path.rsplit_once("::") { Some((p, n)) => (format!("{p}::"), n.to_string()), @@ -108,28 +109,6 @@ fn result_line(e: &Entry, selected: bool, width: u16) -> Line<'static> { Line::from(spans) } -fn kind_badge(k: &rustdoc_types::ItemKind) -> &'static str { - use rustdoc_types::ItemKind as K; - match k { - K::Struct => "struct", - K::Enum => "enum", - K::Trait => "trait", - K::Function => "fn", - K::Module => "mod", - K::Macro => "macro", - K::TypeAlias => "type", - K::Constant => "const", - K::Static => "static", - K::Primitive => "prim", - K::Union => "union", - K::Variant => "variant", - K::AssocConst => "const", - K::AssocType => "type", - K::StructField => "field", - _ => "item", - } -} - /// Rows the item view spends on its header before the body starts. const HEADER_HEIGHT: u16 = 2; From 9e6848aacd65085ad5f756b8330e9ac7701ff7de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 31 Oct 2026 19:03:59 +1100 Subject: [PATCH 04/15] fix: list unions, statics, and attribute and derive macros on module pages --- src/format.rs | 10 ++++++++-- src/page.rs | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/format.rs b/src/format.rs index 648cb6f..18f2863 100644 --- a/src/format.rs +++ b/src/format.rs @@ -2,8 +2,8 @@ use rustdoc_types::{ Abi, FunctionSignature, GenericArg, GenericArgs, GenericBound, GenericParamDef, - GenericParamDefKind, Generics, Item, ItemEnum, PreciseCapturingArg, Term, TraitBoundModifier, - Type, WherePredicate, + GenericParamDefKind, Generics, Item, ItemEnum, MacroKind, PreciseCapturingArg, Term, + TraitBoundModifier, Type, WherePredicate, }; /// Format a type as Rust source. @@ -466,6 +466,12 @@ pub fn signature(item: &Item) -> Option { ItemEnum::Primitive(_) => Some(format!("primitive {name}")), ItemEnum::Module(_) => Some(format!("mod {name}")), ItemEnum::Macro(def) => Some(def.clone()), + // Built-in macros have no source to quote, so show how one is invoked. + ItemEnum::ProcMacro(m) => Some(match m.kind { + MacroKind::Bang => format!("{name}!() {{ /* proc-macro */ }}"), + MacroKind::Attr => format!("#[{name}]"), + MacroKind::Derive => format!("#[derive({name})]"), + }), _ => None, } } diff --git a/src/page.rs b/src/page.rs index dd2e676..cbc6dd7 100644 --- a/src/page.rs +++ b/src/page.rs @@ -143,16 +143,24 @@ mod indent { } /// Module children are listed in this order, matching the docs.rs layout. -const MODULE_KINDS: [(ItemKind, &str); 9] = [ +/// +/// A kind missing from here is silently left out of every module page, which +/// is how `MaybeUninit` (a union) and `core`'s built-in derives once went +/// unlisted. +const MODULE_KINDS: [(ItemKind, &str); 13] = [ (ItemKind::Module, "Modules"), (ItemKind::Macro, "Macros"), (ItemKind::Struct, "Structs"), (ItemKind::Enum, "Enums"), + (ItemKind::Union, "Unions"), (ItemKind::Trait, "Traits"), (ItemKind::Function, "Functions"), (ItemKind::TypeAlias, "Type Aliases"), (ItemKind::Constant, "Constants"), + (ItemKind::Static, "Statics"), (ItemKind::Primitive, "Primitives"), + (ItemKind::ProcAttribute, "Attribute Macros"), + (ItemKind::ProcDerive, "Derive Macros"), ]; /// Collect and group the impls attached to an item. @@ -778,6 +786,35 @@ mod tests { assert!(page.targets.len() > 50, "module children should be targets"); } + /// Every kind a module can hold gets a listing, not just the common ones. + #[test] + fn module_listings_include_unions_and_derive_macros() { + let u = universe(); + let hl = Highlighter::new(); + let listing = |path: &str, title: &str| -> Vec { + let r = u.by_path(path).expect(path); + let page = build(&u, r, 100, &hl, &default_expanded()); + let start = page + .lines + .iter() + .position(|l| text_of(l).contains(title)) + .unwrap_or_else(|| panic!("{path} has no {title} section")); + page.lines[start + 1..] + .iter() + .map(text_of) + .take_while(|l| !l.starts_with(['▾', '▸'])) + .collect() + }; + + let unions = listing("core::mem", "Unions"); + assert!( + unions.iter().any(|l| l.contains("MaybeUninit")), + "{unions:?}" + ); + let derives = listing("core::clone", "Derive Macros"); + assert!(derives.iter().any(|l| l.contains("Clone")), "{derives:?}"); + } + #[test] fn module_listings_hide_crate_internals() { let u = universe(); From bbe2f793adbeee5e5cdce07a02f07f7cb7405276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 31 Oct 2026 19:16:03 +1100 Subject: [PATCH 05/15] refactor: let the page builder own its sections and member rows --- src/app.rs | 25 +++-- src/page.rs | 265 +++++++++++++++++++++++++--------------------------- src/ui.rs | 2 +- 3 files changed, 145 insertions(+), 147 deletions(-) diff --git a/src/app.rs b/src/app.rs index 40b4a51..759cafa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -197,7 +197,7 @@ impl App { let sections: Vec = self .page .as_ref() - .map(|p| p.sections.clone()) + .map(|p| p.sections.iter().map(|s| s.id).collect()) .unwrap_or_default(); for id in sections { if page::is_expanded(id, self.toggled()) { @@ -397,13 +397,13 @@ impl App { fn move_section(&mut self, delta: i32) { let Some(p) = &self.page else { return }; - if p.section_lines.is_empty() { + if p.sections.is_empty() { return; } - let last = p.section_lines.len() - 1; + let last = p.sections.len() - 1; let next = (self.section_cursor as i32 + delta).clamp(0, last as i32) as usize; self.section_cursor = next; - self.scroll = p.section_lines[next] as u16; + self.scroll = p.sections[next].line as u16; self.clamp_scroll(); } @@ -414,7 +414,7 @@ impl App { /// acts on. pub fn toggle_section(&mut self) { let Some(p) = &self.page else { return }; - let Some(section) = p.sections.get(self.section_cursor).copied() else { + let Some(section) = p.sections.get(self.section_cursor).map(|s| s.id) else { self.status = Some("no section selected — use n/p".into()); return; }; @@ -430,9 +430,9 @@ impl App { // Keep the toggled header where the eye already is. if let Some(p) = &self.page - && let Some(l) = p.section_lines.get(self.section_cursor) + && let Some(s) = p.sections.get(self.section_cursor) { - self.scroll = *l as u16; + self.scroll = s.line as u16; self.clamp_scroll(); } } @@ -615,7 +615,7 @@ mod tests { let sections = a.page.as_ref().unwrap().sections.clone(); let blanket = sections .iter() - .position(|g| *g == SectionId::Impls(ImplGroup::Blanket)) + .position(|s| s.id == SectionId::Impls(ImplGroup::Blanket)) .expect("blanket section"); let before = a.page.as_ref().unwrap().lines.len(); @@ -744,7 +744,14 @@ mod tests { // Find `clone` the way the reader would: on String's page, with the // trait impls open. a.navigate_to(sr); - let ids: Vec = a.page.as_ref().unwrap().sections.clone(); + let ids: Vec = a + .page + .as_ref() + .unwrap() + .sections + .iter() + .map(|s| s.id) + .collect(); for id in ids { if !page::is_expanded(id, a.toggled()) { a.toggle_section_id(id); diff --git a/src/page.rs b/src/page.rs index cbc6dd7..0d7ae36 100644 --- a/src/page.rs +++ b/src/page.rs @@ -106,12 +106,20 @@ impl Target { } } -/// A collapsible section of impls. -pub struct Section { +/// The impls of one group, in the order the page lists them. +pub struct ImplSection { pub group: ImplGroup, pub impls: Vec, } +/// A foldable section as laid out on a page: which one, and the line its +/// heading landed on, which is where `n`/`p` scroll to. +#[derive(Debug, Clone, Copy)] +pub struct Section { + pub id: SectionId, + pub line: usize, +} + /// A fully rendered page, ready to be scrolled through. pub struct Page { pub title: String, @@ -119,9 +127,7 @@ pub struct Page { /// header line the item view draws above the scrolling body. pub kind: String, pub lines: Vec>, - /// Line index of each section header, parallel to `sections`. - pub section_lines: Vec, - pub sections: Vec, + pub sections: Vec
, pub targets: Vec, pub width: u16, } @@ -164,7 +170,7 @@ const MODULE_KINDS: [(ItemKind, &str); 13] = [ ]; /// Collect and group the impls attached to an item. -pub fn impls_of(u: &Universe, r: ItemRef) -> Vec
{ +pub fn impls_of(u: &Universe, r: ItemRef) -> Vec { let Some(item) = u.item(r) else { return Vec::new(); }; @@ -177,9 +183,9 @@ pub fn impls_of(u: &Universe, r: ItemRef) -> Vec
{ _ => return Vec::new(), }; - let mut sections: Vec
= ImplGroup::all() + let mut sections: Vec = ImplGroup::all() .into_iter() - .map(|group| Section { + .map(|group| ImplSection { group, impls: Vec::new(), }) @@ -215,25 +221,29 @@ pub fn impls_of(u: &Universe, r: ItemRef) -> Vec
{ /// The context and accumulating output shared by the page-building helpers. /// -/// `u`, `width`, and `hl` are read-only context; `lines` and `targets` are the -/// page being accumulated. Bundling them keeps the helpers from threading the -/// same five parameters through every call. +/// `u`, `width`, `hl`, and `toggled` are read-only context; `lines`, +/// `targets`, and `sections` are the page being accumulated. Bundling them +/// keeps the helpers from threading the same parameters through every call. struct Builder<'a> { u: &'a Universe, width: u16, hl: &'a Highlighter, + toggled: &'a [SectionId], lines: Vec>, targets: Vec, + sections: Vec
, } impl<'a> Builder<'a> { - fn new(u: &'a Universe, width: u16, hl: &'a Highlighter) -> Self { + fn new(u: &'a Universe, width: u16, hl: &'a Highlighter, toggled: &'a [SectionId]) -> Self { Self { u, width, hl, + toggled, lines: Vec::new(), targets: Vec::new(), + sections: Vec::new(), } } @@ -247,28 +257,27 @@ impl<'a> Builder<'a> { } /// Build the page for an item at a given width. +/// +/// `toggled` lists the sections flipped away from their default; see +/// [`is_expanded`]. pub fn build( u: &Universe, r: ItemRef, width: u16, hl: &Highlighter, - expanded: &[SectionId], + toggled: &[SectionId], ) -> Page { - let mut b = Builder::new(u, width, hl); - let mut section_lines = Vec::new(); - let mut sections = Vec::new(); - let Some(item) = u.item(r) else { return Page { title: "".into(), kind: String::new(), lines: vec![Line::styled("item not found", theme::deprecated())], - section_lines, - sections, + sections: Vec::new(), targets: Vec::new(), width, }; }; + let mut b = Builder::new(u, width, hl, toggled); let path = u .path_of(r) @@ -322,32 +331,24 @@ pub fn build( match &item.inner { ItemEnum::Struct(s) => { if let StructKind::Plain { fields, .. } = &s.kind { - b.push_members(r, "Fields", fields); + b.push_members(r, "Fields", fields, member_decl); } } ItemEnum::Enum(e) => { - b.push_variants(r, &e.variants); + b.push_members(r, "Variants", &e.variants, |v| variant_decl(u, r, v)); } ItemEnum::Trait(t) => { - b.push_members(r, "Required Methods", &t.items); + b.push_members(r, "Required Methods", &t.items, member_decl); } ItemEnum::Module(m) => { - b.push_module_contents(r, &m.items, expanded, &mut section_lines, &mut sections); + b.push_module_contents(r, &m.items); } _ => {} } // --- Impl sections ---------------------------------------------------- for section in impls_of(u, r) { - let id = SectionId::Impls(section.group); - let is_open = is_expanded(id, expanded); - section_lines.push(b.lines.len()); - sections.push(id); - - b.push_section_header(id, is_open, section.impls.len()); - - if !is_open { - b.blank(); + if !b.open_section(SectionId::Impls(section.group), section.impls.len()) { continue; } @@ -370,13 +371,7 @@ pub fn build( for mid in &im.items { let mref = ItemRef::new(iref.krate, *mid); let Some(m) = u.item(mref) else { continue }; - let sig = - format::signature(m).unwrap_or_else(|| m.name.clone().unwrap_or_default()); - b.targets.push(Target::line(b.lines.len(), mref)); - for l in hl_code(&sig, hl) { - b.push(indent_line(indent::MEMBER, l)); - } - b.push_docs(mref, m, indent::DOCS); + b.push_member(mref, m, &member_decl(m)); b.blank(); } } @@ -386,8 +381,7 @@ pub fn build( title: path, kind: kind_label.to_string(), lines: b.lines, - section_lines, - sections, + sections: b.sections, targets: b.targets, width, } @@ -429,42 +423,66 @@ impl Builder<'_> { } } - fn push_members(&mut self, parent: ItemRef, title: &str, ids: &[Id]) { - let members: Vec = ids + /// A fixed heading followed by one row per member, each with its docs. + /// + /// Fields, variants, and required methods are all listed this way; only + /// how a member is declared differs, which `decl` supplies. + fn push_members( + &mut self, + parent: ItemRef, + title: &str, + ids: &[Id], + decl: impl Fn(&Item) -> String, + ) { + let u = self.u; + let members: Vec<(ItemRef, &Item)> = ids .iter() .map(|id| ItemRef::new(parent.krate, *id)) - .filter(|m| self.u.item(*m).is_some()) + .filter_map(|m| Some((m, u.item(m)?))) .collect(); if members.is_empty() { return; } - self.lines.push(Line::styled( + self.push(Line::styled( format!("{:indent$}{title}", "", indent = indent::HEADING), theme::section(), )); - for m in members { - let Some(item) = self.u.item(m) else { continue }; - let sig = - format::signature(item).unwrap_or_else(|| item.name.clone().unwrap_or_default()); - self.targets.push(Target::line(self.lines.len(), m)); - self.lines.push(Line::from(vec![ - Span::raw(" ".repeat(indent::MEMBER)), - Span::styled(sig, theme::signature()), - ])); - self.push_docs(m, item, indent::DOCS); + for (m, item) in members { + self.push_member(m, item, &decl(item)); } - self.lines.push(Line::default()); + self.blank(); + } + + /// One member row: its declaration, highlighted like the page's own, then + /// its docs hanging beneath. The row is the member's target, so opening a + /// method focuses this line. + fn push_member(&mut self, m: ItemRef, item: &Item, decl: &str) { + self.targets.push(Target::line(self.lines.len(), m)); + for l in hl_code(decl, self.hl) { + self.push(indent_line(indent::MEMBER, l)); + } + self.push_docs(m, item, indent::DOCS); } - /// A foldable section heading: a marker, the title, and how many members - /// it holds. - fn push_section_header(&mut self, id: SectionId, is_open: bool, count: usize) { + /// Register a foldable section and draw its heading: a marker, the title, + /// and how many members it holds. Returns whether the section is open, + /// i.e. whether its body should follow. + fn open_section(&mut self, id: SectionId, count: usize) -> bool { + let is_open = is_expanded(id, self.toggled); + self.sections.push(Section { + id, + line: self.lines.len(), + }); let marker = if is_open { "▾" } else { "▸" }; self.push(Line::from(vec![ Span::styled(format!("{marker} {} ", id.title()), theme::section()), Span::styled(format!("({count})"), theme::dim()), ])); + if !is_open { + self.blank(); + } + is_open } /// List a module's children, grouped by kind, one line each. @@ -473,14 +491,7 @@ impl Builder<'_> { /// its name and a one-line summary, the way the docs.rs module index does. /// Re-exports are resolved to what they point at, since a `use` item has /// no docs of its own and is not worth navigating to. - fn push_module_contents( - &mut self, - parent: ItemRef, - ids: &[Id], - expanded: &[SectionId], - section_lines: &mut Vec, - sections: &mut Vec, - ) { + fn push_module_contents(&mut self, parent: ItemRef, ids: &[Id]) { // Group by kind, keeping each group in the order rustdoc listed it. let mut groups: Vec<(&'static str, Vec)> = MODULE_KINDS .iter() @@ -532,14 +543,7 @@ impl Builder<'_> { .unwrap_or_default() }); - let id = SectionId::Module(title); - let is_open = is_expanded(id, expanded); - section_lines.push(self.lines.len()); - sections.push(id); - self.push_section_header(id, is_open, members.len()); - - if !is_open { - self.blank(); + if !self.open_section(SectionId::Module(title), members.len()) { continue; } @@ -559,62 +563,52 @@ impl Builder<'_> { self.blank(); } } +} - fn push_variants(&mut self, parent: ItemRef, ids: &[Id]) { - if ids.is_empty() { - return; - } - self.lines.push(Line::styled( - format!("{:indent$}Variants", "", indent = indent::HEADING), - theme::section(), - )); - for id in ids { - let vref = ItemRef::new(parent.krate, *id); - let Some(item) = self.u.item(vref) else { - continue; - }; - let ItemEnum::Variant(v) = &item.inner else { - continue; - }; - let name = item.name.clone().unwrap_or_default(); - let rendered = match &v.kind { - VariantKind::Plain => name.clone(), - VariantKind::Tuple(fields) => { - let tys: Vec = fields - .iter() - .map(|f| { - f.and_then(|fid| self.u.item(ItemRef::new(parent.krate, fid))) - .and_then(|fi| match &fi.inner { - ItemEnum::StructField(t) => Some(format::ty(t)), - _ => None, - }) - .unwrap_or_else(|| "_".into()) - }) - .collect(); - format!("{name}({})", tys.join(", ")) - } - VariantKind::Struct { fields, .. } => { - let fs: Vec = fields - .iter() - .filter_map(|fid| self.u.item(ItemRef::new(parent.krate, *fid))) - .filter_map(|fi| match &fi.inner { - ItemEnum::StructField(t) => { - Some(format!("{}: {}", fi.name.clone()?, format::ty(t))) - } +/// How a member is declared on its row: its signature, or failing that its name. +fn member_decl(item: &Item) -> String { + format::signature(item).unwrap_or_else(|| item.name.clone().unwrap_or_default()) +} + +/// How an enum variant is declared, with its fields' types spelled out. +/// +/// The variant only holds field ids, so this needs the universe to look the +/// fields up, which is why it is not one of [`format::signature`]'s arms. +fn variant_decl(u: &Universe, parent: ItemRef, item: &Item) -> String { + let name = item.name.clone().unwrap_or_default(); + let ItemEnum::Variant(v) = &item.inner else { + return name; + }; + let field = |id: Id| u.item(ItemRef::new(parent.krate, id)); + match &v.kind { + VariantKind::Plain => name, + VariantKind::Tuple(fields) => { + let tys: Vec = fields + .iter() + .map(|f| { + f.and_then(field) + .and_then(|fi| match &fi.inner { + ItemEnum::StructField(t) => Some(format::ty(t)), _ => None, }) - .collect(); - format!("{name} {{ {} }}", fs.join(", ")) - } - }; - self.targets.push(Target::line(self.lines.len(), vref)); - self.lines.push(Line::from(vec![ - Span::raw(" ".repeat(indent::MEMBER)), - Span::styled(rendered, theme::signature()), - ])); - self.push_docs(vref, item, indent::DOCS); + .unwrap_or_else(|| "_".into()) + }) + .collect(); + format!("{name}({})", tys.join(", ")) + } + VariantKind::Struct { fields, .. } => { + let fs: Vec = fields + .iter() + .filter_map(|fid| field(*fid)) + .filter_map(|fi| match &fi.inner { + ItemEnum::StructField(t) => { + Some(format!("{}: {}", fi.name.clone()?, format::ty(t))) + } + _ => None, + }) + .collect(); + format!("{name} {{ {} }}", fs.join(", ")) } - self.lines.push(Line::default()); } } @@ -866,18 +860,13 @@ mod tests { let page = build(&u, root, 100, &hl, &default_expanded()); // A module page's listings register as sections, so n/p steps them. - let titles: Vec<&str> = page.sections.iter().map(|s| s.title()).collect(); + let titles: Vec<&str> = page.sections.iter().map(|s| s.id.title()).collect(); assert!(titles.contains(&"Modules"), "got {titles:?}"); assert!(titles.contains(&"Macros"), "got {titles:?}"); - assert_eq!(page.sections.len(), page.section_lines.len()); // Every registered section heading really is at the line claimed. - for (id, line) in page.sections.iter().zip(&page.section_lines) { - let t: String = page.lines[*line] - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect(); + for Section { id, line } in &page.sections { + let t = text_of(&page.lines[*line]); assert!( t.contains(id.title()), "line {line} is not {:?}: {t:?}", @@ -895,7 +884,7 @@ mod tests { page.lines.len(), folded.lines.len() ); - let titles: Vec<&str> = folded.sections.iter().map(|s| s.title()).collect(); + let titles: Vec<&str> = folded.sections.iter().map(|s| s.id.title()).collect(); assert!( titles.contains(&"Modules"), "heading should survive folding" @@ -1114,7 +1103,9 @@ mod tests { // header, or the next section heading if this was the last impl // in its section. let end = (header + 1..page.lines.len()) - .find(|&i| is_impl_header(&page.lines[i]) || page.section_lines.contains(&i)) + .find(|&i| { + is_impl_header(&page.lines[i]) || page.sections.iter().any(|s| s.line == i) + }) .unwrap_or(page.lines.len()); for sig in header + 1..end { diff --git a/src/ui.rs b/src/ui.rs index ea688a9..27b9baf 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -161,7 +161,7 @@ fn draw_item(f: &mut Frame, app: &mut App, area: Rect) { let focused = app.focus.and_then(|i| page.targets.get(i)); let focused_line = focused.map(|t| t.line); // Mark which section `space` will act on. - let cursor_line = page.section_lines.get(app.section_cursor).copied(); + let cursor_line = page.sections.get(app.section_cursor).map(|s| s.line); let start = app.scroll as usize; let visible: Vec = page From 3555a7efc0fb378ee12aac7f0b9dfa39efc56d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 31 Oct 2026 20:39:09 +1100 Subject: [PATCH 06/15] refactor: carry link ids beside spans instead of tagging underline colours --- src/render.rs | 195 +++++++++++++++++--------------------------------- 1 file changed, 65 insertions(+), 130 deletions(-) diff --git a/src/render.rs b/src/render.rs index 7a31b74..4d7f6a6 100644 --- a/src/render.rs +++ b/src/render.rs @@ -202,25 +202,12 @@ fn strip_hidden(code: &str) -> String { out } -/// Marks a span as belonging to link number `n`. +/// Inline text waiting to be wrapped: each span with the link it belongs to. /// -/// Link extents cannot be recorded when the link is emitted: at that point the -/// text is still an unwrapped run of spans, and `wrap_into` decides only later -/// which line each piece lands on. So links are tagged here and their extents -/// recovered from the wrapped lines afterwards. `underline_color` is unused by -/// this renderer, carries through wrapping untouched, and is cleared once the -/// tag has been read back. -fn tag(style: Style, n: usize) -> Style { - style.underline_color(Color::Indexed(u8::try_from(n % 255).unwrap_or(0) + 1)) -} - -/// Read back the link number a span was tagged with, if any. -fn tag_of(style: Style) -> Option { - match style.underline_color { - Some(Color::Indexed(n)) if n > 0 => Some(usize::from(n - 1)), - _ => None, - } -} +/// A link's extent cannot be recorded when the link is emitted, because only +/// wrapping decides which line each piece lands on. Carrying the link beside +/// the span lets [`flush`] record the extent as it lays the lines out. +type Run = Vec<(Span<'static>, Option)>; /// Render an item's markdown docs to styled, width-wrapped lines. pub fn markdown(docs: &str, links: &HashMap, width: u16, hl: &Highlighter) -> Rendered { @@ -228,15 +215,13 @@ pub fn markdown(docs: &str, links: &HashMap, width: u16, hl: &Highli let mut out = Rendered::default(); // State accumulated across events for the current paragraph. - let mut spans: Vec> = Vec::new(); + let mut run: Run = Vec::new(); let mut style = Style::default(); let mut list_depth: usize = 0; let mut in_code: Option = None; let mut code_lang = "rs"; let mut quote_depth = 0usize; let mut pending_link: Option = None; - // Links tagged in the current unflushed run, indexed by tag number. - let mut pending_ids: Vec = Vec::new(); let mut opts = Options::empty(); opts.insert(Options::ENABLE_STRIKETHROUGH); @@ -252,43 +237,29 @@ pub fn markdown(docs: &str, links: &HashMap, width: u16, hl: &Highli Some((raw.into(), pulldown_cmark::CowStr::Borrowed(""))) }; - // Flush the current inline run as wrapped lines with the given indent, - // then recover the extent of every link tagged within it. - macro_rules! flush { - ($indent:expr) => {{ - if !spans.is_empty() { - let indent: String = $indent; - let from = out.lines.len(); - wrap_into(&mut out.lines, std::mem::take(&mut spans), width, &indent); - collect_links(&mut out, from, &pending_ids); - } - pending_ids.clear(); - }}; - } - let parser = Parser::new_with_broken_link_callback(docs, opts, Some(&mut callback)); for event in parser { match event { Event::Start(Tag::Heading { .. }) => { - flush!(String::new()); + flush(&mut out, &mut run, width, ""); if !out.lines.is_empty() { out.lines.push(Line::default()); } style = theme::heading(); } Event::End(TagEnd::Heading(_)) => { - flush!(String::new()); + flush(&mut out, &mut run, width, ""); style = Style::default(); out.lines.push(Line::default()); } Event::Start(Tag::Paragraph) => {} Event::End(TagEnd::Paragraph) => { let indent = " ".repeat(list_depth) + &"> ".repeat(quote_depth); - flush!(indent); + flush(&mut out, &mut run, width, &indent); out.lines.push(Line::default()); } Event::Start(Tag::CodeBlock(kind)) => { - flush!(String::new()); + flush(&mut out, &mut run, width, ""); code_lang = match &kind { CodeBlockKind::Fenced(info) => code_language(info), CodeBlockKind::Indented => "rs", @@ -305,7 +276,7 @@ pub fn markdown(docs: &str, links: &HashMap, width: u16, hl: &Highli } } Event::Start(Tag::List(_)) => { - flush!(String::new()); + flush(&mut out, &mut run, width, ""); list_depth += 1; } Event::End(TagEnd::List(_)) => { @@ -315,14 +286,14 @@ pub fn markdown(docs: &str, links: &HashMap, width: u16, hl: &Highli } } Event::Start(Tag::Item) => { - spans.push(Span::raw("• ")); + run.push((Span::raw("• "), None)); } Event::End(TagEnd::Item) => { let indent = " ".repeat(list_depth.saturating_sub(1)); - flush!(indent); + flush(&mut out, &mut run, width, &indent); } Event::Start(Tag::BlockQuote(_)) => { - flush!(String::new()); + flush(&mut out, &mut run, width, ""); quote_depth += 1; style = theme::quote(); } @@ -346,33 +317,29 @@ pub fn markdown(docs: &str, links: &HashMap, width: u16, hl: &Highli } Event::Code(text) => { // Inline code may itself be an intra-doc link target. - let key = text.to_string(); - let mut st = theme::inline_code(); - if let Some(id) = lookup(links, &key, pending_link.as_deref()) { - st = tag(theme::link(), pending_ids.len()); - pending_ids.push(id); - } - spans.push(Span::styled(key, st)); + let link = lookup(links, &text, pending_link.as_deref()); + let st = if link.is_some() { + theme::link() + } else { + theme::inline_code() + }; + run.push((Span::styled(text.to_string(), st), link)); } Event::Text(text) => { if let Some(code) = in_code.as_mut() { code.push_str(&text); } else { - let mut st = style; - if let Some(id) = lookup(links, &text, pending_link.as_deref()) { - st = tag(style, pending_ids.len()); - pending_ids.push(id); - } - spans.push(Span::styled(text.to_string(), st)); + let link = lookup(links, &text, pending_link.as_deref()); + run.push((Span::styled(text.to_string(), style), link)); } } - Event::SoftBreak => spans.push(Span::raw(" ")), + Event::SoftBreak => run.push((Span::raw(" "), None)), Event::HardBreak => { let indent = " ".repeat(list_depth); - flush!(indent); + flush(&mut out, &mut run, width, &indent); } Event::Rule => { - flush!(String::new()); + flush(&mut out, &mut run, width, ""); out.lines.push(Line::styled( "─".repeat(width.min(60)), Style::new().fg(Color::DarkGray), @@ -381,7 +348,7 @@ pub fn markdown(docs: &str, links: &HashMap, width: u16, hl: &Highli _ => {} } } - flush!(String::new()); + flush(&mut out, &mut run, width, ""); // Trailing blank lines add nothing but scrolling. while out.lines.last().is_some_and(|l| l.width() == 0) { @@ -421,41 +388,6 @@ fn collapse_blank_runs(out: &mut Rendered) { out.lines = kept; } -/// Recover link extents from lines `from..` and clear the tags. -/// -/// Wrapping may split a tagged run across lines; each line the tag appears on -/// becomes its own `LinkTarget`, so the highlight follows the text exactly. -fn collect_links(out: &mut Rendered, from: usize, ids: &[Id]) { - for line in from..out.lines.len() { - // Run of consecutive spans sharing one tag: (tag, start index). - let mut run: Option<(usize, usize)> = None; - let len = out.lines[line].spans.len(); - for i in 0..=len { - let here = (i < len) - .then(|| tag_of(out.lines[line].spans[i].style)) - .flatten(); - match (run, here) { - (Some((n, _)), Some(m)) if n == m => continue, - (Some((n, start)), _) => { - if let Some(id) = ids.get(n) { - out.links.push(LinkTarget { - line, - spans: start..i, - id: *id, - }); - } - run = here.map(|m| (m, i)); - } - (None, Some(m)) => run = Some((m, i)), - (None, None) => {} - } - } - for span in &mut out.lines[line].spans { - span.style.underline_color = None; - } - } -} - /// Resolve a link by its display text or its destination. /// /// Rustdoc keys `Item.links` by the link text as written, which may or may not @@ -472,17 +404,21 @@ fn lookup(links: &HashMap, text: &str, dest: Option<&str>) -> Option .copied() } -/// Word-wrap a run of spans to `width`, preserving each span's style. -fn wrap_into(out: &mut Vec>, spans: Vec>, width: usize, indent: &str) { +/// Word-wrap the pending run to `width` beneath `indent`, emptying it. +/// +/// Each span keeps its style, and every link records the spans it landed on. +/// Wrapping can split one link across lines, which yields one target per line +/// so the highlight follows the text exactly. +fn flush(out: &mut Rendered, run: &mut Run, width: usize, indent: &str) { let avail = width.saturating_sub(indent.len()).max(8); - let mut current: Vec> = Vec::new(); + let mut current: Run = Vec::new(); let mut used = 0usize; // Continuation lines line up under the first, past any bullet. let cont: String = " ".repeat(indent.len()); let mut first = true; - for span in spans { + for (span, link) in run.drain(..) { let style = span.style; for word in split_keeping_spaces(&span.content) { let w = word.chars().count(); @@ -491,10 +427,7 @@ fn wrap_into(out: &mut Vec>, spans: Vec>, width: usi continue; } if used + w > avail && used > 0 { - out.push(prefixed( - if first { indent } else { &cont }, - std::mem::take(&mut current), - )); + push_wrapped(out, if first { indent } else { &cont }, &mut current); first = false; used = 0; if word.trim().is_empty() { @@ -502,14 +435,41 @@ fn wrap_into(out: &mut Vec>, spans: Vec>, width: usi } } used += w; - current.push(Span::styled(word, style)); + current.push((Span::styled(word, style), link)); } } if !current.is_empty() { - out.push(prefixed(if first { indent } else { &cont }, current)); + push_wrapped(out, if first { indent } else { &cont }, &mut current); } } +/// Emit one wrapped line, recording a target for each link on it. +/// +/// Consecutive spans naming the same item are one link: a link's text is +/// split into words for wrapping, and they must light up together. +fn push_wrapped(out: &mut Rendered, indent: &str, current: &mut Run) { + let line = out.lines.len(); + let mut spans = Vec::with_capacity(current.len() + 1); + if !indent.is_empty() { + spans.push(Span::raw(indent.to_string())); + } + for (span, link) in current.drain(..) { + let i = spans.len(); + if let Some(id) = link { + match out.links.last_mut() { + Some(t) if t.line == line && t.id == id && t.spans.end == i => t.spans.end += 1, + _ => out.links.push(LinkTarget { + line, + spans: i..i + 1, + id, + }), + } + } + spans.push(span); + } + out.lines.push(Line::from(spans)); +} + /// Dress one line of a fenced code block: a gutter rule, then the code, then /// padding out to `width`. /// @@ -548,15 +508,6 @@ const CODE_GUTTER: usize = 2; /// Widest a code block's background is painted, gutter included. const CODE_BLOCK_WIDTH: usize = 100; -fn prefixed(indent: &str, mut spans: Vec>) -> Line<'static> { - if indent.is_empty() { - return Line::from(spans); - } - let mut v = vec![Span::raw(indent.to_string())]; - v.append(&mut spans); - Line::from(v) -} - /// Split text into words, keeping the whitespace as its own chunks. fn split_keeping_spaces(s: &str) -> Vec { let mut out = Vec::new(); @@ -681,22 +632,6 @@ mod tests { assert_eq!(r.links[1].id, Id(2)); } - #[test] - fn link_tags_do_not_survive_into_rendered_spans() { - let hl = Highlighter::new(); - let mut links = HashMap::new(); - links.insert("`None`".to_string(), Id(59)); - let r = markdown("See [`None`] for details.", &links, 60, &hl); - - // The tag is an internal marker; leaving it set would paint a stray - // underline colour in the terminal. - for line in &r.lines { - for span in &line.spans { - assert!(span.style.underline_color.is_none()); - } - } - } - #[test] fn a_link_wrapped_across_lines_yields_one_target_per_line() { let hl = Highlighter::new(); From 36618cd92f8d2509eb68e249475a7ad56f3e020d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 31 Oct 2026 20:48:39 +1100 Subject: [PATCH 07/15] test: assert on page structure rather than scraped text, and drop redundant tests --- src/app.rs | 63 +++----------- src/format.rs | 5 -- src/load.rs | 13 --- src/main.rs | 34 +++----- src/page.rs | 222 ++++++++++++++++++------------------------------ src/render.rs | 155 +++++++++------------------------ src/testdocs.rs | 15 ++++ src/theme.rs | 7 +- src/ui.rs | 71 ++++++---------- 9 files changed, 190 insertions(+), 395 deletions(-) diff --git a/src/app.rs b/src/app.rs index 759cafa..94e5756 100644 --- a/src/app.rs +++ b/src/app.rs @@ -233,7 +233,7 @@ impl App { } /// The sections toggled away from their default on the current page. - fn toggled(&self) -> &[SectionId] { + pub fn toggled(&self) -> &[SectionId] { self.current().map(|e| e.toggled.as_slice()).unwrap_or(&[]) } @@ -563,11 +563,7 @@ fn target_index(page: &Page, item: ItemRef, prefer_decl: bool) -> Option mod tests { use super::*; use crate::page::{ImplGroup, SectionId}; - - fn app() -> App { - let (u, idx) = crate::testdocs::indexed(); - App::new(u, idx) - } + use crate::testdocs::{app, app_on}; #[test] fn starts_on_the_std_root_page() { @@ -582,9 +578,7 @@ mod tests { #[test] fn u_goes_up_to_the_parent_and_records_history() { - let mut a = app(); - let vec_ref = a.universe.by_path("alloc::vec::Vec").expect("Vec"); - a.navigate_to(vec_ref); + let mut a = app_on("alloc::vec::Vec"); assert_eq!(a.page.as_ref().unwrap().title, "std::vec::Vec"); a.go_to_parent(); @@ -608,9 +602,7 @@ mod tests { #[test] fn toggling_a_section_expands_it() { - let mut a = app(); - let vec_ref = a.universe.by_path("alloc::vec::Vec").expect("Vec"); - a.navigate_to(vec_ref); + let mut a = app_on("alloc::vec::Vec"); let sections = a.page.as_ref().unwrap().sections.clone(); let blanket = sections @@ -631,9 +623,7 @@ mod tests { #[test] fn shift_tab_walks_links_backwards() { - let mut a = app(); - let opt = a.universe.by_path("core::option::Option").expect("Option"); - a.navigate_to(opt); + let mut a = app_on("core::option::Option"); a.viewport = 30; a.focus_next_target(); @@ -645,9 +635,7 @@ mod tests { #[test] fn focus_wraps_at_both_ends() { - let mut a = app(); - let opt = a.universe.by_path("core::option::Option").expect("Option"); - a.navigate_to(opt); + let mut a = app_on("core::option::Option"); a.viewport = 30; let last = a.page.as_ref().unwrap().targets.len() - 1; @@ -663,9 +651,7 @@ mod tests { #[test] fn first_shift_tab_starts_from_the_visible_page() { - let mut a = app(); - let opt = a.universe.by_path("core::option::Option").expect("Option"); - a.navigate_to(opt); + let mut a = app_on("core::option::Option"); a.viewport = 30; // With nothing focused, stepping back should pick a target the reader @@ -679,17 +665,6 @@ mod tests { ); } - #[test] - fn jumping_to_bottom_does_not_move_the_section_cursor() { - let mut a = app(); - let vec_ref = a.universe.by_path("alloc::vec::Vec").expect("Vec"); - a.navigate_to(vec_ref); - - a.section_cursor = 2; - a.scroll_to_bottom(); - assert_eq!(a.section_cursor, 2, "G must not change which section folds"); - } - #[test] fn history_records_and_restores_scroll() { let mut a = app(); @@ -712,12 +687,7 @@ mod tests { /// rather than getting a bare page of its own. #[test] fn opening_an_inherent_method_lands_on_its_type() { - let mut a = app(); - let m = a - .universe - .by_path("alloc::string::String::push_str") - .expect("String::push_str"); - a.navigate_to(m); + let a = app_on("alloc::string::String::push_str"); let page = a.page.as_ref().expect("a page"); assert_eq!(page.title, "std::string::String"); @@ -725,7 +695,7 @@ mod tests { let line = a .focus .and_then(|f| page.targets.get(f)) - .map(|t| text_of(&page.lines[t.line])) + .map(|t| page.lines[t.line].to_string()) .expect("no method focused"); assert!( line.trim().starts_with("fn push_str"), @@ -763,7 +733,7 @@ mod tests { .iter() .find(|t| { t.spans.is_none() - && text_of(&page.lines[t.line]).trim() == "fn clone(&self) -> Self" + && page.lines[t.line].to_string().trim() == "fn clone(&self) -> Self" }) .map(|t| t.item) .expect("no clone method on String"); @@ -777,7 +747,7 @@ mod tests { let line = b .focus .and_then(|f| page.targets.get(f)) - .map(|t| text_of(&page.lines[t.line])) + .map(|t| page.lines[t.line].to_string()) .expect("no method focused"); assert_eq!(line.trim(), "fn clone(&self) -> Self"); } @@ -786,9 +756,7 @@ mod tests { /// rustdoc gives it the same `Function` kind as a method. #[test] fn a_free_function_keeps_its_own_page() { - let mut a = app(); - let f = a.universe.by_path("core::mem::swap").expect("mem::swap"); - a.navigate_to(f); + let a = app_on("core::mem::swap"); let page = a.page.as_ref().expect("a page"); assert_eq!(page.title, "std::mem::swap"); @@ -816,11 +784,6 @@ mod tests { assert_eq!(a.page.as_ref().unwrap().title, "std::string::String"); } - /// Flatten a line to its text, for asserting on what was focused. - fn text_of(l: &ratatui::text::Line<'static>) -> String { - l.spans.iter().map(|s| s.content.as_ref()).collect() - } - /// Re-wrapping at a new width moves every line, so a focused target must be /// followed to its new position instead of leaving the scroll behind. #[test] @@ -839,7 +802,7 @@ mod tests { let t = &p.targets[a.focus.expect("focused")]; let line = t.line as u16; (line >= a.scroll && line < a.scroll + a.viewport) - .then(|| text_of(&p.lines[t.line]).trim().to_string()) + .then(|| p.lines[t.line].to_string().trim().to_string()) }; assert!( on_screen(&a).is_some_and(|l| l.starts_with("fn push_str")), diff --git a/src/format.rs b/src/format.rs index 18f2863..151fe2b 100644 --- a/src/format.rs +++ b/src/format.rs @@ -582,11 +582,6 @@ mod tests { #[test] fn drops_empty_where_predicates() { - use rustdoc_types::{GenericParamDef, GenericParamDefKind}; - let _ = (GenericParamDef { - name: "T".into(), - kind: GenericParamDefKind::Lifetime { outlives: vec![] }, - },); // Rustdoc emits `T:` with no bounds for const-trait desugaring; that // must not surface as a dangling `where T:`. let g = Generics { diff --git a/src/load.rs b/src/load.rs index 4f97231..75577e0 100644 --- a/src/load.rs +++ b/src/load.rs @@ -161,16 +161,3 @@ mod tests { assert!(check_format_version(&text, "std").is_ok()); } } - -#[cfg(test)] -mod toolchain_tests { - /// The override exists so CI can pin a nightly; make sure the default is - /// still the plain `nightly` channel. - #[test] - fn defaults_to_nightly() { - // Only meaningful when the caller has not set an override. - if std::env::var_os("RDV_TOOLCHAIN").is_none() { - assert_eq!(super::toolchain(), "nightly"); - } - } -} diff --git a/src/main.rs b/src/main.rs index d7d07df..488babb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -163,11 +163,7 @@ fn item_key(app: &mut App, key: KeyEvent, ctrl: bool) { #[cfg(test)] mod tests { use super::*; - - fn app() -> App { - let (u, idx) = crate::testdocs::indexed(); - App::new(u, idx) - } + use crate::testdocs::{app, app_on}; fn press(app: &mut App, code: KeyCode, modifiers: KeyModifiers) { app.status = None; @@ -178,9 +174,7 @@ mod tests { /// to the parent item, the other scrolls half a page. #[test] fn u_goes_up_while_ctrl_u_scrolls() { - let mut a = app(); - let vec_ref = a.universe.by_path("alloc::vec::Vec").expect("Vec"); - a.navigate_to(vec_ref); + let mut a = app_on("alloc::vec::Vec"); // ^u scrolls within the page, leaving the item alone. a.scroll_by(40); @@ -224,32 +218,24 @@ mod tests { assert!(a.should_quit, "q should quit"); } - /// n/p and space work on a module page's listings, not just on impls. + /// n/p step the section cursor and space toggles the section under it. + /// What folding does to the page is the page's and app's business; this + /// only checks the keys reach it. The landing page is std's root, so this + /// also covers module listings rather than just impls. #[test] - fn module_sections_step_and_fold() { + fn n_p_and_space_drive_the_section_cursor() { let mut a = app(); - let root = a.universe.root().expect("std root"); - a.navigate_to(root); - assert!( - !a.page.as_ref().unwrap().sections.is_empty(), - "std's root page should have sections" - ); + let section = |a: &App| a.page.as_ref().unwrap().sections[a.section_cursor].id; - // n steps to the next section and scrolls it into view. press(&mut a, KeyCode::Char('n'), KeyModifiers::NONE); assert_eq!(a.section_cursor, 1); press(&mut a, KeyCode::Char('p'), KeyModifiers::NONE); assert_eq!(a.section_cursor, 0); - // space folds the section under the cursor. - let before = a.page.as_ref().unwrap().lines.len(); press(&mut a, KeyCode::Char(' '), KeyModifiers::NONE); - let after = a.page.as_ref().unwrap().lines.len(); - assert!(after < before, "space should fold ({before} -> {after})"); - - // ...and again unfolds it. + assert_eq!(a.toggled(), [section(&a)]); press(&mut a, KeyCode::Char(' '), KeyModifiers::NONE); - assert_eq!(a.page.as_ref().unwrap().lines.len(), before); + assert!(a.toggled().is_empty()); } /// On the search screen `u` is literal text, not a command. diff --git a/src/page.rs b/src/page.rs index 0d7ae36..fb837b6 100644 --- a/src/page.rs +++ b/src/page.rs @@ -525,8 +525,8 @@ impl Builder<'_> { self.u.item(*e).and_then(|i| i.name.as_ref()) == resolved.name.as_ref() }) { - // std both declares a primitive and re-exports core's docs for - // it, so the same name can arrive twice; keep the first. + // Re-exports can bring the same name in twice — `std::task` + // would otherwise list its `task` module twice; keep the first. groups[i].1.push(target); } } @@ -755,100 +755,65 @@ mod tests { use super::*; use crate::testdocs::universe; - #[test] - fn module_pages_list_their_contents() { - let u = universe(); - let hl = Highlighter::new(); - let root = u.root().expect("std root"); - let page = build(&u, root, 100, &hl, &default_expanded()); - - let text: Vec = page - .lines + /// What a module page lists: each row's section and name, read from the + /// page's targets and sections rather than parsed back out of its text. + fn listings(u: &Universe, r: ItemRef) -> Vec<(&'static str, String)> { + let page = build(u, r, 100, &Highlighter::new(), &[]); + page.targets .iter() - .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) - .collect(); - - assert!( - text.iter().any(|l| l.contains("Modules")), - "no Modules section" - ); - assert!( - text.iter() - .any(|l| l.trim_start().starts_with("collections")) - ); - // Children are navigable, not just printed. - assert!(page.targets.len() > 50, "module children should be targets"); + .filter(|t| t.spans.is_none()) + .map(|t| { + let section = page + .sections + .iter() + .rev() + .find(|s| s.line < t.line) + .expect("a listed row outside any section"); + let name = u.item(t.item).and_then(|i| i.name.clone()); + (section.id.title(), name.unwrap_or_default()) + }) + .collect() } /// Every kind a module can hold gets a listing, not just the common ones. #[test] - fn module_listings_include_unions_and_derive_macros() { + fn module_pages_list_their_contents() { let u = universe(); - let hl = Highlighter::new(); - let listing = |path: &str, title: &str| -> Vec { - let r = u.by_path(path).expect(path); - let page = build(&u, r, 100, &hl, &default_expanded()); - let start = page - .lines - .iter() - .position(|l| text_of(l).contains(title)) - .unwrap_or_else(|| panic!("{path} has no {title} section")); - page.lines[start + 1..] - .iter() - .map(text_of) - .take_while(|l| !l.starts_with(['▾', '▸'])) - .collect() - }; + let at = |path: &str| listings(&u, u.by_path(path).expect(path)); - let unions = listing("core::mem", "Unions"); + let root = at("std"); assert!( - unions.iter().any(|l| l.contains("MaybeUninit")), - "{unions:?}" + root.contains(&("Modules", "collections".into())), + "{root:?}" + ); + let mem = at("core::mem"); + assert!(mem.contains(&("Unions", "MaybeUninit".into())), "{mem:?}"); + let clone = at("core::clone"); + assert!( + clone.contains(&("Derive Macros", "Clone".into())), + "{clone:?}" ); - let derives = listing("core::clone", "Derive Macros"); - assert!(derives.iter().any(|l| l.contains("Clone")), "{derives:?}"); } + /// A module lists its public API, each item once. The JSON also carries + /// `pub(crate)` internals the website omits, and re-exports can bring the + /// same item in more than once: `std::task` would otherwise list its + /// `task` module twice. #[test] - fn module_listings_hide_crate_internals() { + fn module_listings_show_each_public_child_once() { let u = universe(); - let hl = Highlighter::new(); - let root = u.root().expect("std root"); - let page = build(&u, root, 100, &hl, &default_expanded()); - - // These are `pub(crate)` in the JSON but absent from the website. + let root = listings(&u, u.root().expect("std root")); for internal in ["panicking", "backtrace_rs", "__restricted_std_workaround"] { - let listed = page.lines.iter().any(|l| { - let t: String = l.spans.iter().map(|s| s.content.as_ref()).collect(); - t.trim_start().starts_with(&format!("{internal} ")) || t.trim() == internal - }); - assert!(!listed, "{internal} should not be listed"); + assert!( + root.iter().all(|(_, name)| name != internal), + "{internal} should not be listed" + ); } - } - #[test] - fn module_listings_have_no_duplicate_names_per_section() { - let u = universe(); - let hl = Highlighter::new(); - let root = u.root().expect("std root"); - let page = build(&u, root, 100, &hl, &default_expanded()); - - // std both declares primitives and re-exports core's docs for them. - let mut seen: Vec = Vec::new(); - let mut section = String::new(); - for l in &page.lines { - let t: String = l.spans.iter().map(|s| s.content.as_ref()).collect(); - // Section headings carry a fold marker; members are indented. - if let Some(rest) = t.strip_prefix("▾ ").or_else(|| t.strip_prefix("▸ ")) { - section = rest.trim().to_string(); - continue; - } - if let Some(rest) = t.strip_prefix(" ") { - let name = rest.split_whitespace().next().unwrap_or("").to_string(); - let key = format!("{section}/{name}"); - assert!(!seen.contains(&key), "{key} listed twice"); - seen.push(key); - } + let task = listings(&u, u.by_path("std::task").expect("std::task")); + let mut seen = std::collections::HashSet::new(); + for row in &task { + assert!(seen.insert(row), "{row:?} listed twice"); } } @@ -857,7 +822,7 @@ mod tests { let u = universe(); let hl = Highlighter::new(); let root = u.root().expect("std root"); - let page = build(&u, root, 100, &hl, &default_expanded()); + let page = build(&u, root, 100, &hl, &[]); // A module page's listings register as sections, so n/p steps them. let titles: Vec<&str> = page.sections.iter().map(|s| s.id.title()).collect(); @@ -866,7 +831,7 @@ mod tests { // Every registered section heading really is at the line claimed. for Section { id, line } in &page.sections { - let t = text_of(&page.lines[*line]); + let t = page.lines[*line].to_string(); assert!( t.contains(id.title()), "line {line} is not {:?}: {t:?}", @@ -918,22 +883,6 @@ mod tests { for g in ImplGroup::all() { assert!(groups.contains(&g), "missing group {g:?}"); } - let total: usize = sections.iter().map(|s| s.impls.len()).sum(); - assert!(total > 100, "expected Vec's ~110 impls, got {total}"); - } - - #[test] - fn builds_a_page_for_string() { - let u = universe(); - let hl = Highlighter::new(); - let r = u.by_path("alloc::string::String").expect("String"); - let page = build(&u, r, 80, &hl, &default_expanded()); - - assert!(page.title.ends_with("string::String")); - assert!(page.lines.len() > 20, "page looks empty"); - assert!(!page.sections.is_empty(), "no impl sections"); - // Inherent methods are targets you can navigate to. - assert!(!page.targets.is_empty(), "no navigable targets"); } #[test] @@ -941,19 +890,15 @@ mod tests { let u = universe(); let hl = Highlighter::new(); let r = u.by_path("alloc::string::String").expect("String"); - let page = build(&u, r, 100, &hl, &default_expanded()); + let page = build(&u, r, 100, &hl, &[]); // `String::new` documents itself with a `let s = String::new();` // example; finding it proves method docs reach the markdown renderer // rather than being cut to a one-line summary. - let text: Vec = page + let example = page .lines .iter() - .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) - .collect(); - let example = text - .iter() - .position(|l| l.contains("String::new()")) + .position(|l| l.to_string().contains("String::new()")) .expect("no rendered example from a method's docs"); // Syntax highlighting splits a code line into several coloured spans; @@ -981,7 +926,7 @@ mod tests { .lines .iter() .find(|l| { - let text: String = l.spans.iter().map(|s| s.content.as_ref()).collect(); + let text = l.to_string(); text.trim_start().starts_with("impl ") && text.contains(" for ") }) .expect("no trait impl header on String"); @@ -1013,7 +958,7 @@ mod tests { let u = universe(); let hl = Highlighter::new(); let r = u.by_path("core::option::Option").expect("Option"); - let page = build(&u, r, 100, &hl, &default_expanded()); + let page = build(&u, r, 100, &hl, &[]); let links: Vec<&Target> = page.targets.iter().filter(|t| t.spans.is_some()).collect(); assert!(!links.is_empty(), "no intra-doc link targets on Option"); @@ -1029,35 +974,26 @@ mod tests { } #[test] - fn option_variants_render() { + fn enum_pages_list_their_variants() { let u = universe(); let hl = Highlighter::new(); let r = u.by_path("core::option::Option").expect("Option"); - let page = build(&u, r, 80, &hl, &default_expanded()); - let text: String = page - .lines - .iter() - .map(|l| { - l.spans - .iter() - .map(|s| s.content.as_ref()) - .collect::() - }) - .collect::>() - .join("\n"); - assert!(text.contains("Variants"), "no Variants section"); - assert!(text.contains("None"), "missing None variant"); - assert!(text.contains("Some"), "missing Some variant"); - } + let page = build(&u, r, 80, &hl, &[]); - /// Text of a line, spans joined. - fn text_of(l: &Line<'_>) -> String { - l.spans.iter().map(|s| s.content.as_ref()).collect() + let variants: Vec<&str> = page + .targets + .iter() + .filter(|t| t.spans.is_none()) + .filter_map(|t| u.item(t.item)) + .filter(|i| matches!(i.inner, ItemEnum::Variant(_))) + .filter_map(|i| i.name.as_deref()) + .collect(); + assert_eq!(variants, ["None", "Some"]); } /// Columns of leading whitespace on a line. fn indent_of(l: &Line<'_>) -> usize { - let t = text_of(l); + let t = l.to_string(); t.len() - t.trim_start().len() } @@ -1082,7 +1018,7 @@ mod tests { // sits *above* the signatures around it, so missing one would let the // walk below compare a method against the next impl's heading. let is_impl_header = |l: &Line<'_>| { - let t = text_of(l); + let t = l.to_string(); let t = t.trim_start(); t.starts_with("impl ") || t.starts_with("impl<") }; @@ -1109,15 +1045,15 @@ mod tests { .unwrap_or(page.lines.len()); for sig in header + 1..end { - if !text_of(&page.lines[sig]).trim_start().starts_with("fn ") { + if !page.lines[sig].to_string().trim_start().starts_with("fn ") { continue; } assert_eq!(indent_of(&page.lines[sig]), indent::MEMBER); // Prose before the next signature, if this method has any. let Some(docs) = (sig + 1..end) - .take_while(|&i| !text_of(&page.lines[i]).trim_start().starts_with("fn ")) - .find(|&i| !text_of(&page.lines[i]).trim().is_empty()) + .take_while(|&i| !page.lines[i].to_string().trim_start().starts_with("fn ")) + .find(|&i| !page.lines[i].to_string().trim().is_empty()) else { continue; }; @@ -1126,7 +1062,7 @@ mod tests { "docs at {} are shallower than the signature at {}: {:?}", indent_of(&page.lines[docs]), indent_of(&page.lines[sig]), - text_of(&page.lines[docs]), + page.lines[docs].to_string(), ); checked += 1; } @@ -1144,7 +1080,7 @@ mod tests { let u = universe(); let hl = Highlighter::new(); let r = u.by_path("core::option::Option").expect("Option"); - let page = build(&u, r, WIDTH, &hl, &default_expanded()); + let page = build(&u, r, WIDTH, &hl, &[]); let want = theme::member_docs().bg.expect("panel bg"); let tinted = page @@ -1157,16 +1093,24 @@ mod tests { tinted.width(), WIDTH as usize, "tinted line stops short of the width: {:?}", - text_of(tinted) + tinted.to_string() ); // The page's own top-level docs are not a member's, so they stay plain // — but they are indented to the same rung as one, so the description // hangs off the declaration rather than sitting flush left. + // A link before the first member row sits in the page's own docs. + let first_member = page + .targets + .iter() + .find(|t| t.spans.is_none()) + .expect("no member rows") + .line; let first_prose = page - .lines + .targets .iter() - .find(|l| text_of(l).trim_start().starts_with("The Option")) - .expect("no top-level docs"); + .find(|t| t.spans.is_some() && t.line < first_member) + .map(|t| &page.lines[t.line]) + .expect("no link in Option's own docs"); assert!( first_prose.spans.iter().all(|s| s.style.bg.is_none()), "the page's own docs were tinted like a member's" @@ -1175,7 +1119,7 @@ mod tests { indent_of(first_prose), indent::HEADING, "the page's own docs are not indented: {:?}", - text_of(first_prose) + first_prose.to_string() ); } } diff --git a/src/render.rs b/src/render.rs index 4d7f6a6..6df413c 100644 --- a/src/render.rs +++ b/src/render.rs @@ -586,16 +586,6 @@ mod tests { } } - #[test] - fn finds_intra_doc_links() { - let hl = Highlighter::new(); - let mut links = HashMap::new(); - links.insert("`None`".to_string(), Id(59)); - let r = markdown("See [`None`] for details.", &links, 60, &hl); - assert_eq!(r.links.len(), 1); - assert_eq!(r.links[0].id, Id(59)); - } - /// Text of the spans a link covers, which is what gets highlighted. fn link_text(r: &Rendered, i: usize) -> String { let t = &r.links[i]; @@ -605,16 +595,18 @@ mod tests { .collect() } + /// An intra-doc link resolves to its item and covers only its own text, + /// not the prose around it. #[test] - fn link_extent_covers_only_the_link_text() { + fn finds_intra_doc_links_and_their_extent() { let hl = Highlighter::new(); let mut links = HashMap::new(); links.insert("`None`".to_string(), Id(59)); let r = markdown("See [`None`] for details.", &links, 60, &hl); + assert_eq!(r.links.len(), 1); + assert_eq!(r.links[0].id, Id(59)); assert_eq!(link_text(&r, 0), "None"); - // The surrounding prose must stay outside the highlighted range. - assert!(r.lines[r.links[0].line].width() > 6); } #[test] @@ -665,10 +657,7 @@ mod tests { let lines = hl.highlight(code, "rs"); assert_eq!(lines.len(), 6, "expected one line per source line"); - let flat: Vec = lines - .iter() - .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) - .collect(); + let flat: Vec = lines.iter().map(ToString::to_string).collect(); assert_eq!(flat.join("\n"), code); } @@ -702,53 +691,57 @@ mod tests { } /// A fenced block is dressed as a slab: every line carries the code - /// background out to the full width, with a gutter rule marking its - /// extent for terminals that drop the background. + /// background, with a gutter rule marking its extent for terminals that + /// drop the background. #[test] - fn code_blocks_are_padded_and_ruled() { - const WIDTH: u16 = 60; + fn fenced_code_is_dressed_as_a_slab() { let hl = Highlighter::new(); - let r = markdown( - "text\n\n```\nlet n = 1;\n```\n", - &HashMap::new(), - WIDTH, - &hl, - ); + let r = markdown("text\n\n```\nlet n = 1;\n```\n", &HashMap::new(), 60, &hl); let bg = theme::code_block().bg.expect("code bg"); let code = r .lines .iter() - .find(|l| { - l.spans - .iter() - .map(|s| s.content.as_ref()) - .collect::() - .contains("let n = 1;") - }) + .find(|l| l.to_string().contains("let n = 1;")) .expect("no code line"); - - assert_eq!(code.width(), WIDTH as usize, "code line was not padded out"); + assert!(code.to_string().starts_with("┃ "), "no gutter rule: {code}"); assert!( code.spans.iter().all(|s| s.style.bg == Some(bg)), "a span in the code line missed the background" ); - assert!( - WIDTH as usize <= CODE_BLOCK_WIDTH, - "this case is meant to render narrower than the cap" - ); - let text: String = code.spans.iter().map(|s| s.content.as_ref()).collect(); - assert!(text.starts_with("┃ "), "no gutter rule: {text:?}"); // The prose above it is not code and keeps the page background. let prose = r .lines .iter() - .find(|l| l.spans.iter().any(|s| s.content.as_ref() == "text")) + .find(|l| l.to_string() == "text") .expect("no prose line"); assert!(prose.spans.iter().all(|s| s.style.bg.is_none())); } + /// The slab is padded so a block reads as one piece rather than a ragged + /// stripe, but stops at a reading measure, since past it there is only + /// empty background. A line longer than that keeps its own length: + /// truncating the background partway through the code would look like + /// damage. + #[test] + fn code_slabs_are_padded_up_to_the_reading_measure() { + let bg = theme::code_block().bg.expect("code bg"); + let width = |code: &str, page: usize| { + let line = code_line(Line::raw(code.to_string()), page); + assert!(line.spans.iter().all(|s| s.style.bg == Some(bg))); + line.width() + }; + let long = "y".repeat(CODE_BLOCK_WIDTH); + + assert_eq!(width("let n = 1;", 60), 60, "not padded to the page"); + assert_eq!(width("let n = 1;", CODE_BLOCK_WIDTH * 2), CODE_BLOCK_WIDTH); + assert_eq!( + width(&long, CODE_BLOCK_WIDTH * 2), + CODE_BLOCK_WIDTH + CODE_GUTTER + ); + } + /// A paragraph ends with a blank line and a heading starts with one, so /// the two used to stack into a double gap wherever prose met a heading. #[test] @@ -772,14 +765,7 @@ mod tests { assert!( pair[1] - pair[0] > 1, "consecutive blank lines at {pair:?} in {:?}", - r.lines - .iter() - .map(|l| l - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect::()) - .collect::>() + r.lines.iter().map(ToString::to_string).collect::>() ); } } @@ -799,71 +785,6 @@ mod tests { ); assert_eq!(r.links.len(), 1); - let t = &r.links[0]; - let text: String = r.lines[t.line].spans[t.spans.clone()] - .iter() - .map(|s| s.content.as_ref()) - .collect(); - assert_eq!(text, "None"); - } - - /// Past a comfortable reading measure the slab is empty background, so it - /// stops rather than running to the window edge. - #[test] - fn code_block_backgrounds_stop_at_the_reading_measure() { - let hl = Highlighter::new(); - let wide = CODE_BLOCK_WIDTH as u16 * 2; - let r = markdown("```\nlet n = 1;\n```\n", &HashMap::new(), wide, &hl); - - let code = r - .lines - .iter() - .find(|l| { - l.spans - .iter() - .map(|s| s.content.as_ref()) - .collect::() - .contains("let n = 1;") - }) - .expect("no code line"); - - assert_eq!( - code.width(), - CODE_BLOCK_WIDTH, - "the slab did not stop at the cap" - ); - } - - /// A code line longer than the cap keeps its own length: truncating the - /// background partway through the code would look like damage. - #[test] - fn code_longer_than_the_cap_keeps_its_background() { - let hl = Highlighter::new(); - let long = format!("let x = \"{}\";", "y".repeat(CODE_BLOCK_WIDTH)); - let r = markdown( - &format!("```\n{long}\n```\n"), - &HashMap::new(), - CODE_BLOCK_WIDTH as u16 * 2, - &hl, - ); - - let bg = theme::code_block().bg.expect("code bg"); - let code = r - .lines - .iter() - .find(|l| { - l.spans - .iter() - .map(|s| s.content.as_ref()) - .collect::() - .contains("yyy") - }) - .expect("no code line"); - - assert!(code.width() > CODE_BLOCK_WIDTH, "the case did not overflow"); - assert!( - code.spans.iter().all(|s| s.style.bg == Some(bg)), - "the background was cut short of the code" - ); + assert_eq!(link_text(&r, 0), "None"); } } diff --git a/src/testdocs.rs b/src/testdocs.rs index b80a731..aa6894c 100644 --- a/src/testdocs.rs +++ b/src/testdocs.rs @@ -6,6 +6,7 @@ //! panic with the install hint rather than skipping, so a test that cannot run //! is reported as a failure instead of a pass. +use crate::app::App; use crate::docs::Universe; use crate::index::SearchIndex; use crate::load; @@ -28,3 +29,17 @@ pub fn indexed() -> (Universe, SearchIndex) { let idx = SearchIndex::build(&u); (u, idx) } + +/// An app on its landing page. +pub fn app() -> App { + let (u, idx) = indexed(); + App::new(u, idx) +} + +/// An app showing the item at canonical `path`. +pub fn app_on(path: &str) -> App { + let mut a = app(); + let r = a.universe.by_path(path).expect(path); + a.navigate_to(r); + a +} diff --git a/src/theme.rs b/src/theme.rs index 32fe2c8..a42d804 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -233,7 +233,7 @@ mod tests { #[test] fn every_style_resolves_against_the_theme() { init(); - let styles: [(&str, Style); 19] = [ + let styles = [ ("title", title()), ("section", section()), ("signature", signature()), @@ -245,6 +245,9 @@ mod tests { ("inline_code", inline_code()), ("link", link()), ("quote", quote()), + ("code_block", code_block()), + ("page", page()), + ("member_docs", member_docs()), ("accent", accent()), ("focused_border", focused_border()), ("unfocused_border", unfocused_border()), @@ -252,7 +255,9 @@ mod tests { ("selected_name", selected_name()), ("unselected_name", unselected_name()), ("selected_row", selected_row()), + ("focused_link", focused_link()), ("cursor_line", cursor_line()), + ("status_bar", status_bar()), ]; for (name, style) in styles { diff --git a/src/ui.rs b/src/ui.rs index 27b9baf..2515ecc 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -343,6 +343,25 @@ fn draw_help(f: &mut Frame, area: Rect) { #[cfg(test)] mod tests { use super::*; + use crate::testdocs::app_on; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::buffer::Buffer; + + /// Draw one 100×30 frame of the app showing the item at `path`. + fn drawn(path: &str) -> Buffer { + let mut app = app_on(path); + let mut term = Terminal::new(TestBackend::new(100, 30)).expect("terminal"); + term.draw(|f| draw(f, &mut app)).expect("draw"); + term.backend().buffer().clone() + } + + /// Each screen row as text. + fn rows(buf: &Buffer) -> Vec { + (0..buf.area.height) + .map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect()) + .collect() + } #[test] fn hints_fit_the_width_and_always_keep_the_pinned_one() { @@ -367,33 +386,11 @@ mod tests { assert_eq!(fit_hints(&hints, "? help", 2), "? help"); } - /// The item-view footer is measured in characters, not bytes: the - /// separators and arrows are multi-byte. - #[test] - fn hints_are_measured_in_characters() { - let line = fit_hints(&["⏎ follow", "⇧tab link"], "? help", 30); - assert!(line.chars().count() <= 30, "{line:?}"); - } - /// A method opened from search is not just focused in the model: the row /// is actually painted as focused in the rendered frame. #[test] fn an_opened_method_row_is_highlighted_on_screen() { - use crate::app::App; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - let (u, idx) = crate::testdocs::indexed(); - let mut app = App::new(u, idx); - let m = app - .universe - .by_path("alloc::string::String::push_str") - .expect("String::push_str"); - app.navigate_to(m); - - let mut term = Terminal::new(TestBackend::new(100, 30)).expect("terminal"); - term.draw(|f| draw(f, &mut app)).expect("draw"); - let buf = term.backend().buffer(); + let buf = drawn("alloc::string::String::push_str"); // Find the row carrying the declaration, and confirm it is painted // with the focus background rather than the page background. The name @@ -401,11 +398,10 @@ mod tests { // is also on the page and also starts with `fn push_str`. let want = theme::focused_link().bg.expect("focus bg"); let mut found = false; - for y in 0..buf.area.height { - let row: String = (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect(); + for (y, row) in rows(&buf).iter().enumerate() { if row.contains("fn push_str(") { found = true; - let bg = buf[(6, y)].bg; + let bg = buf[(6, y as u16)].bg; assert_eq!(bg, want, "method row {y} is not highlighted: {row:?}"); } } @@ -417,25 +413,8 @@ mod tests { /// like it had lost its left edge. #[test] fn the_item_view_is_padded_but_the_status_bar_is_not() { - use crate::app::App; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - let (u, idx) = crate::testdocs::indexed(); - let mut app = App::new(u, idx); - let m = app - .universe - .by_path("core::option::Option") - .expect("Option"); - app.navigate_to(m); - - let mut term = Terminal::new(TestBackend::new(100, 30)).expect("terminal"); - term.draw(|f| draw(f, &mut app)).expect("draw"); - let buf = term.backend().buffer(); - - let rows: Vec = (0..buf.area.height) - .map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect()) - .collect(); + let buf = drawn("core::option::Option"); + let rows = rows(&buf); let (status, body) = rows.split_last().expect("at least one row"); // Asserted against a literal margin rather than SIDE_PADDING itself: @@ -471,7 +450,7 @@ mod tests { // The status bar owns its own single leading space and starts there. assert!( - status.starts_with(" /") || status.starts_with(" 0"), + status.starts_with(' ') && !status.starts_with(" "), "the status bar was indented with the page: {status:?}" ); } From 288e584f687bec476bbf51ad25fd4497f7325f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 31 Oct 2026 21:36:00 +1100 Subject: [PATCH 08/15] refactor: clear the status inside handle_key and drop dead key arms --- src/main.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index 488babb..6ed8664 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,13 +50,12 @@ fn run(terminal: &mut ratatui::DefaultTerminal, app: &mut App) -> Result<()> { if !event::poll(Duration::from_millis(250))? { continue; } - match event::read()? { - Event::Key(key) if key.kind == KeyEventKind::Press => { - app.status = None; - handle_key(app, key); - } - Event::Resize(_, _) => {} - _ => {} + // Anything else, a resize included, only needs the redraw at the top + // of the loop. + if let Event::Key(key) = event::read()? + && key.kind == KeyEventKind::Press + { + handle_key(app, key); } if app.should_quit { return Ok(()); @@ -65,6 +64,8 @@ fn run(terminal: &mut ratatui::DefaultTerminal, app: &mut App) -> Result<()> { } fn handle_key(app: &mut App, key: KeyEvent) { + // A status message answers the previous key; the next one dismisses it. + app.status = None; let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); // Help swallows every key except the ones that dismiss it. @@ -155,7 +156,6 @@ fn item_key(app: &mut App, key: KeyEvent, ctrl: bool) { (KeyCode::Char('n'), false) => app.next_section(), (KeyCode::Char('p'), false) => app.prev_section(), (KeyCode::Char(' '), _) => app.toggle_section(), - (KeyCode::Char('?'), _) => app.show_help(), _ => {} } } @@ -166,7 +166,6 @@ mod tests { use crate::testdocs::{app, app_on}; fn press(app: &mut App, code: KeyCode, modifiers: KeyModifiers) { - app.status = None; handle_key(app, KeyEvent::new(code, modifiers)); } From 99efaf010e992a9e8ca2a6341a5a8eb51109420b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 31 Oct 2026 21:37:31 +1100 Subject: [PATCH 09/15] refactor: drop default_expanded and tidy page.rs comments --- src/app.rs | 6 +++++- src/page.rs | 15 +-------------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/app.rs b/src/app.rs index 94e5756..9842034 100644 --- a/src/app.rs +++ b/src/app.rs @@ -24,6 +24,10 @@ struct HistoryEntry { scroll: u16, /// Sections the reader has toggled away from their default state; see /// [`page::is_expanded`]. + /// + /// Deviations rather than the open set, because which sections a page has + /// is not known until it is built — a module's listings depend on what it + /// contains. A fresh page starts empty: every section at its default. toggled: Vec, } @@ -147,7 +151,7 @@ impl App { self.history.push(HistoryEntry { item: target, scroll: 0, - toggled: page::default_expanded(), + toggled: Vec::new(), }); self.cursor = self.history.len(); self.scroll = 0; diff --git a/src/page.rs b/src/page.rs index fb837b6..365fbc5 100644 --- a/src/page.rs +++ b/src/page.rs @@ -612,7 +612,6 @@ fn variant_decl(u: &Universe, parent: ItemRef, item: &Item) -> String { } } -/// Shift a rendered line right by `n` columns. /// Embolden every span of a line, in place of a line-level style. /// /// The spans carry their own colors from the highlighter, and styling each one @@ -659,6 +658,7 @@ fn tinted(line: Line<'static>, width: u16) -> Line<'static> { Line::from(spans) } +/// Shift a rendered line right by `n` columns. fn indent_line(n: usize, line: Line<'static>) -> Line<'static> { let mut spans = vec![Span::raw(" ".repeat(n))]; spans.extend(line.spans); @@ -692,9 +692,6 @@ fn is_public(item: &Item) -> bool { fn summary_line(item: &Item) -> Option { let docs = item.docs.as_ref()?; let first = docs.lines().find(|l| !l.trim().is_empty())?.trim(); - if first.is_empty() { - return None; - } let cleaned = strip_link_brackets(first); Some(cleaned.chars().take(80).collect()) } @@ -732,16 +729,6 @@ fn strip_link_brackets(s: &str) -> String { out } -/// The sections a fresh page has been explicitly toggled open or shut. -/// -/// Empty: every section starts at its own default. Which sections a page even -/// has is not known until it is built — a module's listings depend on what it -/// contains — so this records *deviations* from the defaults rather than the -/// open set, and [`is_expanded`] resolves the two. -pub fn default_expanded() -> Vec { - Vec::new() -} - /// Whether a section is open, given the sections the reader has toggled. /// /// A toggled section is the opposite of its default; everything else sits at From 9bade669fe6eedb412878b3d3f41de9dd8227298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 31 Oct 2026 21:45:48 +1100 Subject: [PATCH 10/15] refactor: tighten App's history, sizing and help state --- src/app.rs | 119 ++++++++++++++++++++++------------------------------ src/main.rs | 15 ++++--- src/ui.rs | 11 ++--- 3 files changed, 62 insertions(+), 83 deletions(-) diff --git a/src/app.rs b/src/app.rs index 9842034..e46ab81 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,7 +15,6 @@ const MIN_PAGE_WIDTH: u16 = 20; pub enum Screen { Search, Item, - Help, } /// One entry in the browsing history. @@ -37,8 +36,9 @@ pub struct App { pub highlighter: Highlighter, pub screen: Screen, - /// The screen to return to when leaving help. - prev_screen: Screen, + /// Whether the help popup is drawn over the screen. An overlay rather than + /// a screen of its own, so closing it needs no memory of what was behind. + pub help: bool, pub should_quit: bool, // Search state @@ -58,12 +58,11 @@ pub struct App { history: Vec, cursor: usize, + /// Rows of page the item view shows; set through [`Self::resize`]. pub viewport: u16, pub status: Option, - /// Width the item view last had room for, used to decide when to re-wrap. - /// This is the padded content width, not the terminal's: it must match what - /// [`Self::ensure_width`] is given or every frame rebuilds the page. - pub last_width: u16, + /// Columns the item view has for the page; set through [`Self::resize`]. + width: u16, } impl App { @@ -73,7 +72,7 @@ impl App { index, highlighter: Highlighter::new(), screen: Screen::Search, - prev_screen: Screen::Search, + help: false, should_quit: false, query: String::new(), results: Vec::new(), @@ -87,7 +86,7 @@ impl App { cursor: 0, viewport: 20, status: None, - last_width: 80, + width: 80, }; app.refresh_search(); // Open on the crate root so there is something to read and navigate @@ -227,15 +226,20 @@ impl App { let Some(i) = target_index(p, item, true) else { return false; }; - let line = p.targets[i].line as u16; + let line = p.targets[i].line; self.focus = Some(i); - // Sit the target a third of the way down rather than at the very top, - // so the impl header above it stays visible. - self.scroll = line.saturating_sub(self.viewport / 3); - self.clamp_scroll(); + self.bring_into_view(line); true } + /// Scroll `line` a third of the way down the viewport rather than to the + /// very top, so what introduces it — an impl header above a method — stays + /// visible. + fn bring_into_view(&mut self, line: usize) { + self.scroll = (line as u16).saturating_sub(self.viewport / 3); + self.clamp_scroll(); + } + /// The sections toggled away from their default on the current page. pub fn toggled(&self) -> &[SectionId] { self.current().map(|e| e.toggled.as_slice()).unwrap_or(&[]) @@ -243,7 +247,7 @@ impl App { /// Flip one section's folded state and rebuild. fn toggle_section_id(&mut self, id: SectionId) { - if let Some(entry) = self.history.get_mut(self.cursor.saturating_sub(1)) { + if let Some(entry) = self.current_mut() { match entry.toggled.iter().position(|s| *s == id) { Some(i) => { entry.toggled.remove(i); @@ -277,7 +281,7 @@ impl App { } fn restore(&mut self) { - if let Some(entry) = self.history.get(self.cursor.saturating_sub(1)) { + if let Some(entry) = self.current() { self.scroll = entry.scroll; self.focus = None; self.section_cursor = 0; @@ -287,17 +291,22 @@ impl App { } fn save_scroll(&mut self) { - if self.cursor > 0 - && let Some(entry) = self.history.get_mut(self.cursor - 1) - { - entry.scroll = self.scroll; + let scroll = self.scroll; + if let Some(entry) = self.current_mut() { + entry.scroll = scroll; } } + /// The history entry for the page on screen. `cursor` counts the entries + /// up to and including it, so it is the one just before the cursor. fn current(&self) -> Option<&HistoryEntry> { self.history.get(self.cursor.checked_sub(1)?) } + fn current_mut(&mut self) -> Option<&mut HistoryEntry> { + self.history.get_mut(self.cursor.checked_sub(1)?) + } + pub fn rebuild_page(&mut self) { let Some(entry) = self.current() else { self.page = None; @@ -345,21 +354,26 @@ impl App { true } - /// Re-render if the terminal width changed since the page was built. - pub fn ensure_width(&mut self, width: u16) { + /// Record the room the item view has, re-wrapping the page if its width + /// changed. + /// + /// Called every frame with the view's own content area — padding already + /// taken off — since that is what the page's lines must fit. + pub fn resize(&mut self, width: u16, height: u16) { + self.viewport = height; + self.width = width; if self .page .as_ref() - .is_some_and(|p| p.width != width.max(MIN_PAGE_WIDTH)) + .is_some_and(|p| p.width != self.page_width()) { self.rebuild_page(); } } - /// The width pages are built at. The item view has no border, so this is - /// the whole terminal; both callers must agree or every frame rebuilds. + /// The width pages are built at. fn page_width(&self) -> u16 { - self.last_width.max(MIN_PAGE_WIDTH) + self.width.max(MIN_PAGE_WIDTH) } // --- Scrolling -------------------------------------------------------- @@ -422,15 +436,7 @@ impl App { self.status = Some("no section selected — use n/p".into()); return; }; - - if let Some(entry) = self.history.get_mut(self.cursor.saturating_sub(1)) { - if let Some(i) = entry.toggled.iter().position(|g| *g == section) { - entry.toggled.remove(i); - } else { - entry.toggled.push(section); - } - } - self.rebuild_page(); + self.toggle_section_id(section); // Keep the toggled header where the eye already is. if let Some(p) = &self.page @@ -488,16 +494,12 @@ impl App { .unwrap_or(last), }); - // Scroll the focused target into view. - if let Some(f) = self.focus - && let Some(t) = p.targets.get(f) - { - let line = t.line as u16; - if line < self.scroll || line >= self.scroll + self.viewport { - self.scroll = line.saturating_sub(self.viewport / 3); - } + // Scroll the focused target into view, if it is not already. + let line = self.focus.and_then(|f| p.targets.get(f)).map(|t| t.line); + match line { + Some(l) if !(start..end).contains(&l) => self.bring_into_view(l), + _ => self.clamp_scroll(), } - self.clamp_scroll(); } /// Follow the focused target, if any. @@ -526,23 +528,6 @@ impl App { None => self.status = Some("already at the top".into()), } } - - pub fn show_help(&mut self) { - if self.screen != Screen::Help { - self.prev_screen = self.screen; - self.screen = Screen::Help; - } - } - - pub fn close_help(&mut self) { - if self.screen == Screen::Help { - self.screen = self.prev_screen; - } - } - - pub fn has_page(&self) -> bool { - self.page.is_some() - } } /// The index of the target for `item`, preferring the row that declares it. @@ -628,7 +613,7 @@ mod tests { #[test] fn shift_tab_walks_links_backwards() { let mut a = app_on("core::option::Option"); - a.viewport = 30; + a.resize(80, 30); a.focus_next_target(); a.focus_next_target(); @@ -640,7 +625,7 @@ mod tests { #[test] fn focus_wraps_at_both_ends() { let mut a = app_on("core::option::Option"); - a.viewport = 30; + a.resize(80, 30); let last = a.page.as_ref().unwrap().targets.len() - 1; // Backwards off the front wraps to the final target. @@ -656,7 +641,7 @@ mod tests { #[test] fn first_shift_tab_starts_from_the_visible_page() { let mut a = app_on("core::option::Option"); - a.viewport = 30; + a.resize(80, 30); // With nothing focused, stepping back should pick a target the reader // can see rather than jumping to the end of a long page. @@ -793,8 +778,7 @@ mod tests { #[test] fn a_focused_method_survives_a_width_change() { let mut a = app(); - a.last_width = 80; - a.viewport = 25; + a.resize(80, 25); let m = a .universe .by_path("alloc::string::String::push_str") @@ -814,8 +798,7 @@ mod tests { ); // Same page, rebuilt for a wider terminal. - a.last_width = 140; - a.ensure_width(140); + a.resize(140, 25); assert!( on_screen(&a).is_some_and(|l| l.starts_with("fn push_str")), "the focused method scrolled off after the resize" diff --git a/src/main.rs b/src/main.rs index 6ed8664..195fdab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,10 +69,9 @@ fn handle_key(app: &mut App, key: KeyEvent) { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); // Help swallows every key except the ones that dismiss it. - if app.screen == Screen::Help { - match key.code { - KeyCode::Char('?') | KeyCode::Esc | KeyCode::Char('q') => app.close_help(), - _ => {} + if app.help { + if matches!(key.code, KeyCode::Char('?' | 'q') | KeyCode::Esc) { + app.help = false; } return; } @@ -84,7 +83,7 @@ fn handle_key(app: &mut App, key: KeyEvent) { return; } (KeyCode::Char('?'), _) if app.screen != Screen::Search => { - app.show_help(); + app.help = true; return; } _ => {} @@ -93,7 +92,6 @@ fn handle_key(app: &mut App, key: KeyEvent) { match app.screen { Screen::Search => search_key(app, key, ctrl), Screen::Item => item_key(app, key, ctrl), - Screen::Help => {} } } @@ -102,7 +100,7 @@ fn search_key(app: &mut App, key: KeyEvent, ctrl: bool) { (KeyCode::Esc, _) => { // Esc dismisses the search and returns to the page behind it. // Only `q` quits, so with no page to go back to it does nothing. - if app.has_page() { + if app.page.is_some() { app.screen = Screen::Item; } } @@ -207,9 +205,10 @@ mod tests { // Help: Esc closes the popup. press(&mut a, KeyCode::Char('?'), KeyModifiers::NONE); - assert_eq!(a.screen, Screen::Help); + assert!(a.help); press(&mut a, KeyCode::Esc, KeyModifiers::NONE); assert!(!a.should_quit, "esc should not quit the help"); + assert!(!a.help); assert_eq!(a.screen, Screen::Item); // q still quits. diff --git a/src/ui.rs b/src/ui.rs index 2515ecc..9169946 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -27,11 +27,11 @@ pub fn draw(f: &mut Frame, app: &mut App) { match app.screen { Screen::Search => draw_search(f, app, chunks[0]), - Screen::Item | Screen::Help => draw_item(f, app, chunks[0]), + Screen::Item => draw_item(f, app, chunks[0]), } draw_status(f, app, chunks[1]); - if app.screen == Screen::Help { + if app.help { draw_help(f, area); } } @@ -121,16 +121,13 @@ const SIDE_PADDING: u16 = 2; fn draw_item(f: &mut Frame, app: &mut App, area: Rect) { // Inset before anything measures the area: the page is built to this - // width, and `ensure_width` must be told the same number the lines are - // wrapped to or every frame rebuilds the page. + // width, so it is the one the app must be told. let area = Rect { x: area.x + SIDE_PADDING, width: area.width.saturating_sub(SIDE_PADDING * 2), ..area }; - app.viewport = area.height.saturating_sub(HEADER_HEIGHT); - app.last_width = area.width; - app.ensure_width(area.width); + app.resize(area.width, area.height.saturating_sub(HEADER_HEIGHT)); let Some(page) = &app.page else { f.render_widget( From b04b0b8bd6da9332b9f89a8ae3e6f7876d82c5dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 31 Oct 2026 21:51:59 +1100 Subject: [PATCH 11/15] refactor: derive the syntax theme from DEFAULT_THEME and tidy render.rs --- CLAUDE.md | 15 ++-- src/page.rs | 22 ++---- src/render.rs | 191 +++++++++++++++++++++++++------------------------- src/theme.rs | 30 ++++++++ src/ui.rs | 11 ++- 5 files changed, 143 insertions(+), 126 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6baf70d..e1b9644 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,13 +134,14 @@ until its section is opened. All styling goes through `theme.rs`, which resolves opaline's semantic tokens (`accent.primary`, `text.dim`, `bg.highlight`) and named styles (`dimmed`, `inline_code`, `focused_border`). **Do not write `Color::*` literals in UI code** -— add a named style to `theme.rs` instead. Both opaline and arborium default to -Catppuccin Mocha. - -Opaline identifies builtin themes by *kebab*-case id (`catppuccin-mocha`), while -arborium's function is `catppuccin_mocha()`. Getting the id wrong falls back to -opaline's default silently — everything still renders, just in the wrong colors, -which is why `theme.rs` has a test asserting the named theme loads. +— add a named style to `theme.rs` instead. + +`theme::DEFAULT_THEME` is the one setting: an opaline *kebab*-case id +(`catppuccin-mocha`). The syntax theme is derived from it — `theme::syntax` +finds the arborium theme whose display name ("Catppuccin Mocha") kebab-cases to +that id — so switching themes is a one-line change. Getting the id wrong falls +back silently — everything still renders, just in the wrong colors — which is +why `theme.rs` tests that both the opaline and the arborium theme are found. Themes load at runtime, so styles are functions, not `const`s. diff --git a/src/page.rs b/src/page.rs index 365fbc5..072aab5 100644 --- a/src/page.rs +++ b/src/page.rs @@ -315,7 +315,7 @@ pub fn build( // --- Declaration ------------------------------------------------------ if let Some(sig) = format::signature(item) { - for l in hl_code(&sig, hl) { + for l in render::declaration(&sig, hl) { b.push(indent_line(indent::HEADING, l)); } b.blank(); @@ -360,7 +360,7 @@ pub fn build( // The header is highlighted like the signatures it introduces, and // stays bold so it still reads as their heading. - for l in hl_code(&format::impl_header(im), hl) { + for l in render::declaration(&format::impl_header(im), hl) { b.push(indent_line(indent::HEADING, bold(l))); } @@ -459,7 +459,7 @@ impl Builder<'_> { /// method focuses this line. fn push_member(&mut self, m: ItemRef, item: &Item, decl: &str) { self.targets.push(Target::line(self.lines.len(), m)); - for l in hl_code(decl, self.hl) { + for l in render::declaration(decl, self.hl) { self.push(indent_line(indent::MEMBER, l)); } self.push_docs(m, item, indent::DOCS); @@ -638,12 +638,10 @@ fn bold(line: Line<'static>) -> Line<'static> { /// tint fills the gaps around them rather than flattening them out. fn tinted(line: Line<'static>, width: u16) -> Line<'static> { let bg = theme::member_docs(); - let mut used = 0usize; - let mut spans: Vec> = line + let spans: Vec> = line .spans .into_iter() .map(|s| { - used += s.content.chars().count(); let style = if s.style.bg.is_some() { s.style } else { @@ -652,10 +650,7 @@ fn tinted(line: Line<'static>, width: u16) -> Line<'static> { s.style(style) }) .collect(); - if used < width as usize { - spans.push(Span::styled(" ".repeat(width as usize - used), bg)); - } - Line::from(spans) + render::pad_to(Line::from(spans), width as usize, bg) } /// Shift a rendered line right by `n` columns. @@ -665,13 +660,6 @@ fn indent_line(n: usize, line: Line<'static>) -> Line<'static> { Line::from(spans) } -/// Render a declaration through the syntax highlighter. -fn hl_code(code: &str, hl: &Highlighter) -> Vec> { - let mut lines = render::declaration(code, hl); - lines.retain(|l| l.width() > 0); - lines -} - /// Whether an item belongs in a module listing. /// /// The JSON carries crate-internal items (`std::sys`, `std::panicking`) diff --git a/src/render.rs b/src/render.rs index 6df413c..54dae17 100644 --- a/src/render.rs +++ b/src/render.rs @@ -3,7 +3,7 @@ use std::cell::RefCell; use std::collections::HashMap; -use arborium_theme::{HIGHLIGHTS, Theme, builtin}; +use arborium_theme::{HIGHLIGHTS, Theme}; use pulldown_cmark::{BrokenLink, CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; @@ -47,34 +47,26 @@ impl Highlighter { pub fn new() -> Self { Self { inner: RefCell::new(arborium::Highlighter::new()), - // theme: builtin::catppuccin_mocha(), - theme: builtin::catppuccin_latte(), + theme: theme::syntax(), } } - /// Highlight a block of code, returning one styled line per source line. + /// Highlight Rust code, returning one styled line per source line. /// - /// Only Rust is highlighted; anything else renders as plain text, as does - /// code the grammar cannot parse. - fn highlight(&self, code: &str, lang: &str) -> Vec> { + /// Rust is the only grammar built in. Code it cannot parse falls back to + /// [`plain`]. + fn highlight(&self, code: &str) -> Vec> { // Arborium trims trailing newlines itself, so span offsets are relative // to the trimmed source. Trim up front to keep our slicing in step. let code = code.trim_end_matches('\n'); - let plain = || code.lines().map(|l| Line::from(l.to_string())).collect(); - - // `code_language` speaks rustdoc's fence tokens; arborium wants grammar - // names, and Rust is the only grammar we build in. - if lang != "rs" { - return plain(); - } let Ok(spans) = self.inner.borrow_mut().highlight_spans("rust", code) else { - return plain(); + return plain(code); }; // Raw spans overlap and are unordered; flattening resolves them into a // sorted, disjoint token stream, innermost match winning. let tokens = arborium_highlight::spans_to_flat_tokens(code, spans); if tokens.is_empty() { - return plain(); + return plain(code); } let mut lines: Vec> = vec![Line::default()]; @@ -144,42 +136,27 @@ impl Default for Highlighter { } } -/// Strip rustdoc's fence annotations and pick a highlighting language. +/// Code as unstyled lines, for anything that is not Rust. +fn plain(code: &str) -> Vec> { + code.trim_end_matches('\n') + .lines() + .map(|l| Line::from(l.to_string())) + .collect() +} + +/// Whether a fenced block is Rust, judging by its info string. /// /// Rustdoc fences carry attributes rather than a language — ```` ```ignore -/// (needs windows) ````, ```` ```no_run ````, ```` ```should_panic ````. Any -/// token that is not a known language means "this is still Rust", which is -/// rustdoc's own default for an unannotated block. -pub fn code_language(info: &str) -> &'static str { - const ATTRS: [&str; 10] = [ - "ignore", - "no_run", - "should_panic", - "compile_fail", - "edition2015", - "edition2018", - "edition2021", - "edition2024", - "test_harness", - "standalone_crate", +/// (needs windows) ````, ```` ```no_run ````, ```` ```should_panic ```` — and +/// rustdoc treats any block not naming another language as Rust. So only a +/// known other language rules it out. +fn is_rust(info: &str) -> bool { + const OTHER_LANGUAGES: [&str; 13] = [ + "text", "txt", "toml", "json", "sh", "bash", "shell", "console", "c", "cpp", "c++", + "python", "py", ]; - let first = info.trim().split([',', ' ']).next().unwrap_or("").trim(); - if first.is_empty() || ATTRS.contains(&first) || first.starts_with("edition") { - return "rs"; - } - match first { - "rust" | "rs" => "rs", - "text" | "txt" => "txt", - "toml" => "toml", - "json" => "json", - "sh" | "bash" | "shell" | "console" => "sh", - "c" => "c", - "cpp" | "c++" => "cpp", - "python" | "py" => "py", - // Unknown annotation: rustdoc still treats the block as Rust. - _ => "rs", - } + !OTHER_LANGUAGES.contains(&first) } /// Drop rustdoc's hidden lines (`# ...`), matching what the website shows. @@ -219,7 +196,7 @@ pub fn markdown(docs: &str, links: &HashMap, width: u16, hl: &Highli let mut style = Style::default(); let mut list_depth: usize = 0; let mut in_code: Option = None; - let mut code_lang = "rs"; + let mut code_is_rust = true; let mut quote_depth = 0usize; let mut pending_link: Option = None; @@ -260,16 +237,21 @@ pub fn markdown(docs: &str, links: &HashMap, width: u16, hl: &Highli } Event::Start(Tag::CodeBlock(kind)) => { flush(&mut out, &mut run, width, ""); - code_lang = match &kind { - CodeBlockKind::Fenced(info) => code_language(info), - CodeBlockKind::Indented => "rs", + code_is_rust = match &kind { + CodeBlockKind::Fenced(info) => is_rust(info), + CodeBlockKind::Indented => true, }; in_code = Some(String::new()); } Event::End(TagEnd::CodeBlock) => { if let Some(code) = in_code.take() { let code = strip_hidden(&code); - for line in hl.highlight(&code, code_lang) { + let lines = if code_is_rust { + hl.highlight(&code) + } else { + plain(&code) + }; + for line in lines { out.lines.push(code_line(line, width)); } out.lines.push(Line::default()); @@ -340,10 +322,8 @@ pub fn markdown(docs: &str, links: &HashMap, width: u16, hl: &Highli } Event::Rule => { flush(&mut out, &mut run, width, ""); - out.lines.push(Line::styled( - "─".repeat(width.min(60)), - Style::new().fg(Color::DarkGray), - )); + out.lines + .push(Line::styled("─".repeat(width.min(60)), theme::rule())); } _ => {} } @@ -484,26 +464,28 @@ fn code_line(line: Line<'static>, width: usize) -> Line<'static> { Span::styled("┃", theme::dim().patch(bg)), Span::styled(" ", bg), ]; - let mut used = CODE_GUTTER; - for span in line.spans { - used += span.content.chars().count(); - let style = span.style.patch(bg); - spans.push(span.style(style)); - } + spans.extend(line.spans.into_iter().map(|s| { + let style = s.style.patch(bg); + s.style(style) + })); // Stop the slab at a comfortable reading measure rather than running it to // the window edge: rustdoc examples are written to roughly this width, so // past it the background is empty anyway and only makes the block louder. // A line longer than the cap keeps its own length — cutting the background // mid-code would look like damage. - let pad_to = width.min(CODE_BLOCK_WIDTH); - if used < pad_to { - spans.push(Span::styled(" ".repeat(pad_to - used), bg)); - } - Line::from(spans) + pad_to(Line::from(spans), width.min(CODE_BLOCK_WIDTH), bg) } -/// Columns [`code_line`] spends on the gutter before the code itself. -const CODE_GUTTER: usize = 2; +/// Pad a line with spaces in `style` out to `width` columns, so a background +/// reads as one block rather than stopping at the text's ragged end. A line +/// already that wide is left alone. +pub fn pad_to(mut line: Line<'static>, width: usize, style: Style) -> Line<'static> { + let used = line.width(); + if used < width { + line.push_span(Span::styled(" ".repeat(width - used), style)); + } + line +} /// Widest a code block's background is painted, gutter included. const CODE_BLOCK_WIDTH: usize = 100; @@ -531,9 +513,12 @@ fn split_keeping_spaces(s: &str) -> Vec { /// inside prose gets. /// /// A signature is a heading, not an example: it carries no gutter, no -/// background, and no indent of its own, so its caller decides where it sits. +/// background, no indent of its own, and no blank lines, so it sits as one +/// compact block wherever its caller puts it. pub fn declaration(code: &str, hl: &Highlighter) -> Vec> { - hl.highlight(code, "rs") + let mut lines = hl.highlight(code); + lines.retain(|l| l.width() > 0); + lines } /// Convenience: render an item's own docs. @@ -548,18 +533,23 @@ pub fn item_docs(item: &Item, width: u16, hl: &Highlighter) -> Rendered { mod tests { use super::*; + /// Rustdoc's fence attributes and unknown annotations still mean Rust; + /// only a named other language does not. #[test] - fn strips_rustdoc_fence_attributes() { - assert_eq!(code_language(""), "rs"); - assert_eq!(code_language("ignore (needs windows)"), "rs"); - assert_eq!(code_language("no_run"), "rs"); - assert_eq!(code_language("should_panic"), "rs"); - assert_eq!(code_language("compile_fail,edition2021"), "rs"); - assert_eq!(code_language("edition2024"), "rs"); - assert_eq!(code_language("text"), "txt"); - assert_eq!(code_language("toml"), "toml"); - // An unknown annotation still means Rust, per rustdoc's default. - assert_eq!(code_language("weird_attr"), "rs"); + fn fences_are_rust_unless_they_name_another_language() { + for info in [ + "", + "rust", + "ignore (needs windows)", + "no_run", + "compile_fail,edition2021", + "weird_attr", + ] { + assert!(is_rust(info), "{info:?} should be Rust"); + } + for info in ["text", "toml", "sh", "console"] { + assert!(!is_rust(info), "{info:?} should not be Rust"); + } } #[test] @@ -654,7 +644,7 @@ mod tests { // multi-line tokens. let code = "pub fn demo(x: &str) -> Option {\n // a comment\n let n = x.parse().ok()?;\n\n Some(n + 1)\n}"; - let lines = hl.highlight(code, "rs"); + let lines = hl.highlight(code); assert_eq!(lines.len(), 6, "expected one line per source line"); let flat: Vec = lines.iter().map(ToString::to_string).collect(); @@ -664,7 +654,7 @@ mod tests { #[test] fn rust_code_is_highlighted() { let hl = Highlighter::new(); - let lines = hl.highlight("let n = 1;", "rs"); + let lines = hl.highlight("let n = 1;"); // `let` is a keyword and must be coloured differently from the rest. assert!( lines[0].spans.iter().any(|s| s.style.fg.is_some()), @@ -673,21 +663,30 @@ mod tests { ); } - /// Rust is the only grammar compiled in, so every other fence renders as - /// plain text rather than failing. + /// A fence naming another language renders as plain text rather than + /// being run through the Rust grammar. #[test] - fn other_languages_fall_back_to_plain_text() { + fn other_languages_render_unhighlighted() { let hl = Highlighter::new(); - let lines = hl.highlight("[package]\nname = \"x\"", "toml"); - assert_eq!(lines.len(), 2); - assert!(lines.iter().all(|l| l.spans.len() == 1)); - assert!(lines.iter().all(|l| l.spans[0].style.fg.is_none())); + let r = markdown( + "```toml\n[package]\nname = \"x\"\n```\n", + &HashMap::new(), + 60, + &hl, + ); + let code = r + .lines + .iter() + .find(|l| l.to_string().contains("[package]")) + .expect("no code line"); + // Gutter, its space, the text, and the padding: no per-token spans. + assert_eq!(code.spans.len(), 4, "{code:?}"); } #[test] fn empty_code_block_does_not_panic() { let hl = Highlighter::new(); - assert!(hl.highlight("", "rs").len() <= 1); + assert!(hl.highlight("").len() <= 1); } /// A fenced block is dressed as a slab: every line carries the code @@ -736,9 +735,9 @@ mod tests { assert_eq!(width("let n = 1;", 60), 60, "not padded to the page"); assert_eq!(width("let n = 1;", CODE_BLOCK_WIDTH * 2), CODE_BLOCK_WIDTH); - assert_eq!( - width(&long, CODE_BLOCK_WIDTH * 2), - CODE_BLOCK_WIDTH + CODE_GUTTER + assert!( + width(&long, CODE_BLOCK_WIDTH * 2) > CODE_BLOCK_WIDTH, + "long code was cut" ); } diff --git a/src/theme.rs b/src/theme.rs index a42d804..e3011a3 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -23,6 +23,21 @@ use ratatui::style::{Modifier, Style}; // pub const DEFAULT_THEME: &str = "catppuccin-mocha"; pub const DEFAULT_THEME: &str = "catppuccin-latte"; +/// The syntax-highlighting theme matching [`DEFAULT_THEME`], so code and +/// chrome always come from the same palette. +/// +/// Arborium names its themes for display ("Catppuccin Latte") where opaline +/// uses kebab-case ids, so the id is derived from the name rather than kept as +/// a second setting that could drift. Falls back to Catppuccin Mocha, the +/// default of both, when arborium has no theme by that id. +pub fn syntax() -> arborium_theme::Theme { + let builtin = arborium_theme::builtin::all(); + builtin + .into_iter() + .find(|t| t.name.to_lowercase().replace(' ', "-") == DEFAULT_THEME) + .unwrap_or_else(arborium_theme::builtin::catppuccin_mocha) +} + /// Install the default theme as the process-wide active theme. /// /// Falls back to opaline's own default if the named theme is somehow missing, @@ -116,6 +131,12 @@ pub fn link() -> Style { fg(tokens::INFO).add_modifier(Modifier::UNDERLINED) } +/// A horizontal rule (`---`) between passages of docs: a divider, drawn like +/// the border of a panel that does not have focus. +pub fn rule() -> Style { + fg(tokens::BORDER_UNFOCUSED) +} + /// A block quote. pub fn quote() -> Style { style(styles::DIMMED).add_modifier(Modifier::ITALIC) @@ -228,6 +249,14 @@ mod tests { ); } + /// The syntax theme is found by name, not just defaulted: a mismatch + /// would highlight code in one palette and draw everything else in another. + #[test] + fn the_syntax_theme_matches_the_default_theme() { + let name = syntax().name.to_lowercase().replace(' ', "-"); + assert_eq!(name, DEFAULT_THEME); + } + /// Every style this module exposes must resolve to a real color rather than /// falling back to a default, which is how a mistyped token name shows up. #[test] @@ -245,6 +274,7 @@ mod tests { ("inline_code", inline_code()), ("link", link()), ("quote", quote()), + ("rule", rule()), ("code_block", code_block()), ("page", page()), ("member_docs", member_docs()), diff --git a/src/ui.rs b/src/ui.rs index 9169946..43d1a09 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2,12 +2,14 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use crate::app::{App, Screen}; use crate::index::Entry; use crate::kind; +use crate::render; use crate::theme; pub fn draw(f: &mut Frame, app: &mut App) { @@ -92,7 +94,7 @@ fn result_line(e: &Entry, selected: bool, width: u16) -> Line<'static> { None => (String::new(), e.path.clone()), }; - let mut spans = vec![ + let spans = vec![ Span::styled(marker, theme::accent()), Span::styled(format!("{kind:<9}"), theme::kind_badge()), Span::styled(parent, theme::dim()), @@ -100,11 +102,8 @@ fn result_line(e: &Entry, selected: bool, width: u16) -> Line<'static> { ]; if selected { // Pad so the highlight spans the row. - let used: usize = spans.iter().map(|s| s.content.chars().count()).sum(); - if (used as u16) < width { - spans.push(Span::raw(" ".repeat(width as usize - used))); - } - return Line::from(spans).style(theme::selected_row()); + let line = render::pad_to(Line::from(spans), width as usize, Style::default()); + return line.style(theme::selected_row()); } Line::from(spans) } From 560a744416673fa69fb00270417286e0ab54f514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 31 Oct 2026 22:10:34 +1100 Subject: [PATCH 12/15] refactor: share path, reference and parameter formatting in format.rs --- src/format.rs | 231 +++++++++++++++++++++----------------------------- 1 file changed, 97 insertions(+), 134 deletions(-) diff --git a/src/format.rs b/src/format.rs index 151fe2b..edb9b0a 100644 --- a/src/format.rs +++ b/src/format.rs @@ -1,63 +1,35 @@ //! Rendering rustdoc's type and signature structures back into Rust syntax. use rustdoc_types::{ - Abi, FunctionSignature, GenericArg, GenericArgs, GenericBound, GenericParamDef, - GenericParamDefKind, Generics, Item, ItemEnum, MacroKind, PreciseCapturingArg, Term, - TraitBoundModifier, Type, WherePredicate, + Abi, Enum, FunctionSignature, GenericArg, GenericArgs, GenericBound, GenericParamDef, + GenericParamDefKind, Generics, Item, ItemEnum, MacroKind, Path, PreciseCapturingArg, Struct, + Term, TraitBoundModifier, Type, Union, WherePredicate, }; /// Format a type as Rust source. pub fn ty(t: &Type) -> String { match t { - Type::ResolvedPath(p) => { - let mut s = p.path.clone(); - if let Some(args) = &p.args { - s.push_str(&generic_args(args)); - } - s - } + Type::ResolvedPath(p) => path(p), Type::Generic(g) => g.clone(), Type::Primitive(p) => p.clone(), Type::DynTrait(d) => { let mut parts: Vec = d .traits .iter() - .map(|pt| { - let mut s = String::new(); - if !pt.generic_params.is_empty() { - s.push_str(&hrtb(&pt.generic_params)); - } - s.push_str(&pt.trait_.path); - if let Some(a) = &pt.trait_.args { - s.push_str(&generic_args(a)); - } - s - }) + .map(|pt| format!("{}{}", hrtb(&pt.generic_params), path(&pt.trait_))) .collect(); if let Some(lt) = &d.lifetime { parts.push(lt.clone()); } format!("dyn {}", parts.join(" + ")) } - Type::FunctionPointer(f) => { - let mut s = String::new(); - if !f.generic_params.is_empty() { - s.push_str(&hrtb(&f.generic_params)); - } - s.push_str(&header_prefix( - f.header.is_unsafe, - false, - false, - &f.header.abi, - )); - s.push_str("fn"); - s.push_str(&fn_params(&f.sig, false)); - if let Some(out) = &f.sig.output { - s.push_str(" -> "); - s.push_str(&ty(out)); - } - s - } + Type::FunctionPointer(f) => format!( + "{}{}fn{}{}", + hrtb(&f.generic_params), + header_prefix(f.header.is_unsafe, false, false, &f.header.abi), + pointer_params(&f.sig), + returns(&f.sig.output), + ), Type::Tuple(items) => { let inner: Vec = items.iter().map(ty).collect(); // A 1-tuple needs the trailing comma to stay a tuple. @@ -86,18 +58,7 @@ pub fn ty(t: &Type) -> String { lifetime, is_mutable, type_, - } => { - let mut s = String::from("&"); - if let Some(lt) = lifetime { - s.push_str(lt); - s.push(' '); - } - if *is_mutable { - s.push_str("mut "); - } - s.push_str(&ty(type_)); - s - } + } => format!("{}{}", reference(lifetime, *is_mutable), ty(type_)), Type::QualifiedPath { name, args, @@ -119,12 +80,44 @@ pub fn ty(t: &Type) -> String { } } -/// `for<'a, 'b> ` prefix used by HRTBs. +/// The `for<'a, 'b> ` prefix of a higher-ranked bound, or empty when there +/// is nothing to bind. fn hrtb(params: &[GenericParamDef]) -> String { + if params.is_empty() { + return String::new(); + } let names: Vec<&str> = params.iter().map(|p| p.name.as_str()).collect(); format!("for<{}> ", names.join(", ")) } +/// A path with its generic arguments: `Iterator`. +fn path(p: &Path) -> String { + let args = p.args.as_deref().map(generic_args).unwrap_or_default(); + format!("{}{args}", p.path) +} + +/// The `&'a mut ` that opens a reference, shared by reference types and +/// `&self` receivers. +fn reference(lifetime: &Option, is_mutable: bool) -> String { + let mut s = String::from("&"); + if let Some(lt) = lifetime { + s.push_str(lt); + s.push(' '); + } + if is_mutable { + s.push_str("mut "); + } + s +} + +/// The ` -> T` of a signature, or empty for one returning `()`. +fn returns(output: &Option) -> String { + output + .as_ref() + .map(|t| format!(" -> {}", ty(t))) + .unwrap_or_default() +} + fn header_prefix(is_unsafe: bool, is_const: bool, is_async: bool, abi: &Abi) -> String { let mut s = String::new(); if is_const { @@ -178,12 +171,7 @@ pub fn generic_args(args: &GenericArgs) -> String { } GenericArgs::Parenthesized { inputs, output } => { let ins: Vec = inputs.iter().map(ty).collect(); - let mut s = format!("({})", ins.join(", ")); - if let Some(o) = output { - s.push_str(" -> "); - s.push_str(&ty(o)); - } - s + format!("({}){}", ins.join(", "), returns(output)) } GenericArgs::ReturnTypeNotation => "(..)".to_string(), } @@ -217,20 +205,12 @@ fn bound(b: &GenericBound) -> String { generic_params, modifier, } => { - let mut s = String::new(); - if !generic_params.is_empty() { - s.push_str(&hrtb(generic_params)); - } - match modifier { - TraitBoundModifier::None => {} - TraitBoundModifier::Maybe => s.push('?'), - TraitBoundModifier::MaybeConst => s.push_str("~const "), - } - s.push_str(&trait_.path); - if let Some(a) = &trait_.args { - s.push_str(&generic_args(a)); - } - s + let modifier = match modifier { + TraitBoundModifier::None => "", + TraitBoundModifier::Maybe => "?", + TraitBoundModifier::MaybeConst => "~const ", + }; + format!("{}{modifier}{}", hrtb(generic_params), path(trait_)) } GenericBound::Outlives(lt) => lt.clone(), GenericBound::Use(args) => { @@ -310,16 +290,12 @@ pub fn where_clause(g: &Generics) -> String { type_, bounds, generic_params, - } => { - let mut s = String::new(); - if !generic_params.is_empty() { - s.push_str(&hrtb(generic_params)); - } - s.push_str(&ty(type_)); - s.push_str(": "); - s.push_str(&bound_list(bounds)); - s - } + } => format!( + "{}{}: {}", + hrtb(generic_params), + ty(type_), + bound_list(bounds) + ), WherePredicate::LifetimePredicate { lifetime, outlives } => { format!("{}: {}", lifetime, outlives.join(" + ")) } @@ -335,22 +311,28 @@ pub fn where_clause(g: &Generics) -> String { } } -/// Parameter list, rendering a leading receiver as `&self` / `self` etc. -fn fn_params(sig: &FunctionSignature, allow_self: bool) -> String { - let mut parts: Vec = Vec::new(); - for (i, (name, t)) in sig.inputs.iter().enumerate() { - if allow_self && i == 0 && name == "self" { - parts.push(self_param(t)); - continue; - } - if allow_self { - parts.push(format!("{name}: {}", ty(t))); +/// A declared function's parameter list, rendering a leading receiver the way +/// it is written: `(&mut self, n: usize)`. +fn fn_params(sig: &FunctionSignature) -> String { + let params = sig.inputs.iter().enumerate().map(|(i, (name, t))| { + if i == 0 && name == "self" { + self_param(t) } else { - // Function pointers name their params, but the type reads better bare. - parts.push(ty(t)); + format!("{name}: {}", ty(t)) } - } - if sig.is_c_variadic { + }); + param_list(params, sig.is_c_variadic) +} + +/// A function pointer's parameter list. Pointers can name their parameters, +/// but the types read better bare: `fn(usize) -> bool`. +fn pointer_params(sig: &FunctionSignature) -> String { + param_list(sig.inputs.iter().map(|(_, t)| ty(t)), sig.is_c_variadic) +} + +fn param_list(params: impl Iterator, is_c_variadic: bool) -> String { + let mut parts: Vec = params.collect(); + if is_c_variadic { parts.push("...".to_string()); } format!("({})", parts.join(", ")) @@ -365,18 +347,7 @@ fn self_param(t: &Type) -> String { is_mutable, type_, } => match type_.as_ref() { - Type::Generic(g) if g == "Self" => { - let mut s = String::from("&"); - if let Some(lt) = lifetime { - s.push_str(lt); - s.push(' '); - } - if *is_mutable { - s.push_str("mut "); - } - s.push_str("self"); - s - } + Type::Generic(g) if g == "Self" => format!("{}self", reference(lifetime, *is_mutable)), _ => format!("self: {}", ty(t)), }, _ => format!("self: {}", ty(t)), @@ -397,29 +368,14 @@ pub fn signature(item: &Item) -> Option { s.push_str("fn "); s.push_str(name); s.push_str(&generic_params(&f.generics)); - s.push_str(&fn_params(&f.sig, true)); - if let Some(out) = &f.sig.output { - s.push_str(" -> "); - s.push_str(&ty(out)); - } + s.push_str(&fn_params(&f.sig)); + s.push_str(&returns(&f.sig.output)); s.push_str(&where_clause(&f.generics)); Some(s) } - ItemEnum::Struct(st) => { - let mut s = format!("struct {name}{}", generic_params(&st.generics)); - s.push_str(&where_clause(&st.generics)); - Some(s) - } - ItemEnum::Enum(e) => { - let mut s = format!("enum {name}{}", generic_params(&e.generics)); - s.push_str(&where_clause(&e.generics)); - Some(s) - } - ItemEnum::Union(u) => Some(format!( - "union {name}{}{}", - generic_params(&u.generics), - where_clause(&u.generics) - )), + ItemEnum::Struct(Struct { generics, .. }) => Some(type_decl("struct", name, generics)), + ItemEnum::Enum(Enum { generics, .. }) => Some(type_decl("enum", name, generics)), + ItemEnum::Union(Union { generics, .. }) => Some(type_decl("union", name, generics)), ItemEnum::Trait(t) => { let mut s = String::new(); if t.is_unsafe { @@ -476,6 +432,16 @@ pub fn signature(item: &Item) -> Option { } } +/// A type's declaration line: `struct Vec`. Its +/// fields or variants are listed beneath, not spelled out here. +fn type_decl(keyword: &str, name: &str, generics: &Generics) -> String { + format!( + "{keyword} {name}{}{}", + generic_params(generics), + where_clause(generics) + ) +} + /// The header line for an impl block, e.g. `impl Display for Vec`. pub fn impl_header(im: &rustdoc_types::Impl) -> String { let mut s = String::from("impl"); @@ -485,10 +451,7 @@ pub fn impl_header(im: &rustdoc_types::Impl) -> String { if im.is_negative { s.push('!'); } - s.push_str(&tr.path); - if let Some(a) = &tr.args { - s.push_str(&generic_args(a)); - } + s.push_str(&path(tr)); s.push_str(" for "); } s.push_str(&ty(&im.for_)); @@ -609,6 +572,6 @@ mod tests { output: None, is_c_variadic: false, }; - assert_eq!(fn_params(&sig, true), "(&self)"); + assert_eq!(fn_params(&sig), "(&self)"); } } From 4497e3200b4e9cb94a37b154d26fabba30afc080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 31 Oct 2026 22:13:19 +1100 Subject: [PATCH 13/15] fix: show deprecation notices in bold so they outweigh unstable ones --- src/theme.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/theme.rs b/src/theme.rs index e3011a3..03ac2aa 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -95,13 +95,16 @@ pub fn muted() -> Style { style(styles::MUTED) } -/// A deprecation notice. +/// A deprecation notice: the warning colour, in bold, so it stands out from +/// [`unstable`]. pub fn deprecated() -> Style { - style(styles::WARNING_STYLE) + style(styles::WARNING_STYLE).add_modifier(Modifier::BOLD) } /// An unstable-feature notice: a caveat rather than a defect, so it reads a -/// step softer than [`deprecated`]. +/// step softer than [`deprecated`] — the same colour, without the weight. +/// Nightly docs mark a good share of items unstable, so this is also the one +/// that must not shout. pub fn unstable() -> Style { style(styles::WARNING_STYLE) } @@ -239,6 +242,14 @@ mod tests { use super::*; use ratatui::style::Color; + /// A deprecation is a defect and an unstable feature only a caveat; once + /// both notices were the same style, which said they mattered equally. + #[test] + fn deprecation_outweighs_instability() { + init(); + assert_ne!(deprecated(), unstable()); + } + /// The named theme must actually exist: a typo here would silently leave /// opaline's default theme in place. #[test] From d28990c43881c748bd35c3a23fdd9d04573036ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 31 Oct 2026 22:17:55 +1100 Subject: [PATCH 14/15] fix: decide whether a code block is Rust the way rustdoc does --- src/render.rs | 125 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 107 insertions(+), 18 deletions(-) diff --git a/src/render.rs b/src/render.rs index 54dae17..5933009 100644 --- a/src/render.rs +++ b/src/render.rs @@ -144,19 +144,66 @@ fn plain(code: &str) -> Vec> { .collect() } -/// Whether a fenced block is Rust, judging by its info string. +/// Whether a fenced block is Rust, judging by its info string the way +/// rustdoc's `LangString::parse` does. /// -/// Rustdoc fences carry attributes rather than a language — ```` ```ignore -/// (needs windows) ````, ```` ```no_run ````, ```` ```should_panic ```` — and -/// rustdoc treats any block not naming another language as Rust. So only a -/// known other language rules it out. +/// A block is Rust unless it carries a word rustdoc does not know (`text`, +/// `toml`, a typo) — any such word is taken to name another language — or the +/// `custom` tag. A doctest attribute (`no_run`, `ignore`, ...) or `rust` itself +/// keeps it Rust despite an unknown word, though the first few only count when +/// they come before it. `edition20xx` is neutral, as are `(comments)` and +/// `{attribute blocks}`. fn is_rust(info: &str) -> bool { - const OTHER_LANGUAGES: [&str; 13] = [ - "text", "txt", "toml", "json", "sh", "bash", "shell", "console", "c", "cpp", "c++", - "python", "py", - ]; - let first = info.trim().split([',', ' ']).next().unwrap_or("").trim(); - !OTHER_LANGUAGES.contains(&first) + let mut seen_rust = false; + let mut seen_other = false; + for token in fence_tokens(info) { + match token { + "rust" => seen_rust = true, + "custom" => return false, + "should_panic" | "no_run" | "ignore" => seen_rust = !seen_other, + t if t.starts_with("ignore-") => seen_rust = !seen_other, + "test_harness" | "compile_fail" | "standalone_crate" => { + seen_rust = !seen_other || seen_rust; + } + t if t.starts_with("edition") => {} + _ => seen_other = true, + } + } + !seen_other || seen_rust +} + +/// The words of a fence's info string, with `(comments)` and `{attribute +/// blocks}` dropped, as rustdoc's tag iterator reads them. +fn fence_tokens(info: &str) -> Vec<&str> { + let mut tokens = Vec::new(); + let mut start = None; + let mut skip_until = None; + for (i, c) in info.char_indices() { + if let Some(close) = skip_until { + if c == close { + skip_until = None; + } + continue; + } + match c { + ' ' | ',' | '\t' | '(' | '{' => { + if let Some(s) = start.take() { + tokens.push(&info[s..i]); + } + skip_until = match c { + '(' => Some(')'), + '{' => Some('}'), + _ => None, + }; + } + _ if start.is_none() => start = Some(i), + _ => {} + } + } + if let Some(s) = start { + tokens.push(&info[s..]); + } + tokens } /// Drop rustdoc's hidden lines (`# ...`), matching what the website shows. @@ -245,9 +292,10 @@ pub fn markdown(docs: &str, links: &HashMap, width: u16, hl: &Highli } Event::End(TagEnd::CodeBlock) => { if let Some(code) = in_code.take() { - let code = strip_hidden(&code); + // Hidden lines are a doctest feature; in a shell or TOML + // example a leading `#` is a comment the reader should see. let lines = if code_is_rust { - hl.highlight(&code) + hl.highlight(&strip_hidden(&code)) } else { plain(&code) }; @@ -533,25 +581,66 @@ pub fn item_docs(item: &Item, width: u16, hl: &Highlighter) -> Rendered { mod tests { use super::*; - /// Rustdoc's fence attributes and unknown annotations still mean Rust; - /// only a named other language does not. + /// The cases rustdoc's `LangString::parse` distinguishes: doctest + /// attributes keep a block Rust, any unknown word makes it another + /// language unless a Rust tag outweighs it, and order matters for the + /// attributes that only count before an unknown word. #[test] - fn fences_are_rust_unless_they_name_another_language() { + fn fences_are_rust_the_way_rustdoc_decides() { for info in [ "", "rust", "ignore (needs windows)", "no_run", "compile_fail,edition2021", - "weird_attr", + "ignore-wasm32", + "ignore,text", + "text,rust", + "rust {.example}", ] { assert!(is_rust(info), "{info:?} should be Rust"); } - for info in ["text", "toml", "sh", "console"] { + for info in [ + "text", + "toml", + "console", + "weird_attr", + "text,ignore", + "rust2021", + "custom", + ] { assert!(!is_rust(info), "{info:?} should not be Rust"); } } + #[test] + fn fence_tokens_drop_comments_and_attribute_blocks() { + assert_eq!(fence_tokens("ignore (needs windows)"), ["ignore"]); + assert_eq!( + fence_tokens("rust,no_run {.class} edition2021"), + ["rust", "no_run", "edition2021"] + ); + assert_eq!(fence_tokens(" , "), Vec::<&str>::new()); + } + + /// A `#` line is hidden only in Rust, where it is a doctest feature; in + /// another language it is a comment and stays. + #[test] + fn hidden_lines_are_only_stripped_from_rust() { + let hl = Highlighter::new(); + let render = |md: &str| -> Vec { + markdown(md, &HashMap::new(), 60, &hl) + .lines + .iter() + .map(ToString::to_string) + .collect() + }; + let rust = render("```\n# use std::fmt;\nlet x = 1;\n```\n"); + assert!(!rust.iter().any(|l| l.contains("use std::fmt")), "{rust:?}"); + let sh = render("```sh\n# install it\ncargo add x\n```\n"); + assert!(sh.iter().any(|l| l.contains("# install it")), "{sh:?}"); + } + #[test] fn removes_hidden_lines() { let code = "# use std::fmt;\nlet x = 1;\n# hidden();\n"; From 6f83b8f0fc05f31495aa3a4ab64bb187ab1e9b49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 31 Oct 2026 22:26:10 +1100 Subject: [PATCH 15/15] chore: switch the default theme to Catppuccin Mocha --- src/theme.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/theme.rs b/src/theme.rs index 03ac2aa..ba140b2 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -20,8 +20,8 @@ use ratatui::style::{Modifier, Style}; /// /// Opaline identifies builtins by kebab-case id, not by the underscored name /// of the arborium function that produces the matching syntax theme. -// pub const DEFAULT_THEME: &str = "catppuccin-mocha"; -pub const DEFAULT_THEME: &str = "catppuccin-latte"; +// pub const DEFAULT_THEME: &str = "catppuccin-latte"; +pub const DEFAULT_THEME: &str = "catppuccin-mocha"; /// The syntax-highlighting theme matching [`DEFAULT_THEME`], so code and /// chrome always come from the same palette.