diff --git a/.gitignore b/.gitignore index feab64e..c811e69 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ # Rust build artifacts /target -**/*.rs.bk -# Plugin runtime: downloaded LSP binary lives here (see SPEC.md §7.2) +# Plugin runtime: downloaded LSP binary lives here (see development/SPEC.md) /bin +# nuwiki-rs is cloned here during install fallback, not tracked +nuwiki-rs/ # Editor / OS noise .DS_Store diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index f9f4dd9..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,835 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "auto_impl" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "icu_collections" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locid" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" - -[[package]] -name = "icu_normalizer" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "utf16_iter", - "utf8_iter", - "write16", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" - -[[package]] -name = "icu_properties" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locid_transform", - "icu_properties_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" - -[[package]] -name = "icu_provider" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", - "writeable", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "litemap" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "lsp-types" -version = "0.94.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" -dependencies = [ - "bitflags 1.3.2", - "serde", - "serde_json", - "serde_repr", - "url", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "nuwiki-core" -version = "0.4.2" - -[[package]] -name = "nuwiki-ls" -version = "0.4.2" -dependencies = [ - "nuwiki-lsp", - "tokio", -] - -[[package]] -name = "nuwiki-lsp" -version = "0.4.2" -dependencies = [ - "async-trait", - "dashmap 6.1.0", - "nuwiki-core", - "serde", - "serde_json", - "tokio", - "tower-lsp", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project" -version = "1.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tinystr" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "pin-project-lite", - "tokio-macros", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "pin-project", - "pin-project-lite", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-lsp" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ba052b54a6627628d9b3c34c176e7eda8359b7da9acd497b9f20998d118508" -dependencies = [ - "async-trait", - "auto_impl", - "bytes", - "dashmap 5.5.3", - "futures", - "httparse", - "lsp-types", - "memchr", - "serde", - "serde_json", - "tokio", - "tokio-util", - "tower", - "tower-lsp-macros", - "tracing", -] - -[[package]] -name = "tower-lsp-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - -[[package]] -name = "writeable" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" - -[[package]] -name = "yoke" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerovec" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index f29f391..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[workspace] -resolver = "2" -members = [ - "crates/nuwiki-core", - "crates/nuwiki-lsp", - "crates/nuwiki-ls", -] - -[workspace.package] -version = "0.4.2" -edition = "2021" -rust-version = "1.83" -authors = ["Gabriel Fróes Franco "] -license = "MIT OR Apache-2.0" -repository = "https://code.gfran.co/gffranco/nuwiki" -homepage = "https://code.gfran.co/gffranco/nuwiki" - -[workspace.lints.rust] -unsafe_code = "forbid" - -[workspace.lints.clippy] -all = { level = "warn", priority = -1 } diff --git a/README.md b/README.md index 04b5478..72fcf9a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ nuwiki replaces the original [vimwiki](https://github.com/vimwiki/vimwiki) plugin while keeping its file format, keymaps, and `:Vimwiki*` command surface intact. The substantive work happens in a Rust LSP daemon — Vim and Neovim are thin client layers that wire up keystrokes and display -results. +results. The `nuwiki-ls` binary is distributed separately in the +[nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository. ## Features @@ -72,9 +73,12 @@ results. The plugin ships a `nuwiki-ls` binary that the editor talks to over LSP stdio. The `install()` helper downloads a pre-built release for your -platform, falling back to `cargo build --release` if no asset matches. -You can run it any time with `:NuwikiInstall` (works in both Vim and -Neovim) instead of relying on a plugin-manager build hook. +platform from the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) +repository. If the download fails (or `curl`/`tar` are unavailable), it +falls back to cloning nuwiki-rs and building with `cargo` — which requires +a Rust toolchain (1.83+ stable). You can run install manually any time with +`:NuwikiInstall` (works in both Vim and Neovim) instead of relying on a +plugin-manager build hook. ### Requirements @@ -86,8 +90,10 @@ Neovim) instead of relying on a plugin-manager build hook. [`coc.nvim`](https://github.com/neoclide/coc.nvim) — both auto-registered, no manual config (see [Vim LSP client setup](#vim-lsp-client-setup)). -- **Rust toolchain (1.83+ stable)** — only needed when building the - binary from source; pre-built release downloads skip this. +- **curl + tar** — for downloading pre-built release binaries. +- **Rust toolchain (1.83+ stable)** — only needed when building + the binary from source as a fallback (when no pre-built asset matches + your platform or curl/tar are unavailable). ### lazy.nvim @@ -126,10 +132,13 @@ call dein#add('gffranco/nuwiki', { ```sh git clone https://code.gfran.co/gffranco/nuwiki ~/.vim/pack/gffranco/start/nuwiki -cd ~/.vim/pack/gffranco/start/nuwiki && cargo build --release -p nuwiki-ls -mkdir -p bin && cp target/release/nuwiki-ls bin/ +cd ~/.vim/pack/gffranco/start/nuwiki && vim -e -s -c "source scripts/download_bin.vim" -c "q" ``` +If the download fails, `download_bin.vim` will clone +[nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) and build from source +(requires `cargo`). + ### Vim LSP client setup Neovim 0.11+ registers the server through its built-in LSP client @@ -196,9 +205,13 @@ aliases. ./development/start-vim.sh # same, for plain Vim (clones vim-lsp on first run) ``` -Both scripts build the LSP binary, seed a scratch wiki, and launch the +Both scripts download the LSP binary, seed a scratch wiki, and launch the editor against an isolated state dir under `$XDG_CACHE_HOME/nuwiki-dev`. +Alternatively, set `g:nuwiki_binary_path` to point at a `nuwiki-ls` +binary built from the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) +repository. + --- ## Configuration @@ -441,7 +454,6 @@ For users configuring without Lua. | `g:nuwiki_no_folding` | `0` | `1` skips foldexpr setup | | `g:nuwiki_mouse_mappings` | `0` | `1` enables mouse maps | | `g:nuwiki_binary_path` | _(auto)_ | path to a prebuilt `nuwiki-ls` binary, bypassing the bundled one | -| `g:nuwiki_build_from_source` | `0` | `1` builds the binary with `cargo` instead of downloading a release | | `g:nuwiki_no_calendar` | `0` | `1` opts out of calendar-vim integration | | `g:nuwiki_auto_chdir` | `0` | `1` `:lcd` into the wiki root when a wiki buffer becomes current | | `g:nuwiki_auto_header` | `0` | `1` insert a level-1 header from the filename on new wiki pages | diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index 01b1367..5922c28 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -1175,7 +1175,7 @@ endfunction " Mirrors `lua/nuwiki/commands.lua`'s `align_table_at` so plain Vim " buffers get the same "tighten columns as cells grow" behaviour the " Neovim path enjoys, without needing a vim-lsp roundtrip. Algorithm -" matches `crates/nuwiki-lsp/src/commands.rs::ops::render_aligned_table`. +" matches `nuwiki-lsp/src/commands.rs::ops::render_aligned_table` (nuwiki-rs repo). function! s:is_table_row(line) abort return a:line =~# '^\s*|.*|\s*$' diff --git a/crates/nuwiki-core/Cargo.toml b/crates/nuwiki-core/Cargo.toml deleted file mode 100644 index 8b9eb13..0000000 --- a/crates/nuwiki-core/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "nuwiki-core" -description = "Core parser, AST, and renderer for nuwiki — editor-independent." -version.workspace = true -edition.workspace = true -rust-version.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true - -[lints] -workspace = true - -[dependencies] diff --git a/crates/nuwiki-core/src/ast/block.rs b/crates/nuwiki-core/src/ast/block.rs deleted file mode 100644 index fb59e98..0000000 --- a/crates/nuwiki-core/src/ast/block.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Block-level AST nodes and their supporting types. - -use super::inline::InlineNode; -use super::span::Span; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum BlockNode { - Heading(HeadingNode), - Paragraph(ParagraphNode), - HorizontalRule(HorizontalRuleNode), - Blockquote(BlockquoteNode), - Preformatted(PreformattedNode), - MathBlock(MathBlockNode), - List(ListNode), - DefinitionList(DefinitionListNode), - Table(TableNode), - Comment(CommentNode), - /// Vimwiki tag line — `:tag1:tag2:`. The placement - /// rule (file / heading / standalone) is captured in `TagScope` so - /// LSP handlers and renderers can treat the three differently. - Tag(TagNode), - Error(ErrorNode), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HeadingNode { - pub span: Span, - /// 1..=6 — invariant enforced by the parser, not the type. - pub level: u8, - pub children: Vec, - pub centered: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParagraphNode { - pub span: Span, - pub children: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct HorizontalRuleNode { - pub span: Span, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BlockquoteNode { - pub span: Span, - pub children: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PreformattedNode { - pub span: Span, - pub content: String, - pub language: Option, - /// Verbatim text after the opening `{{{`, trailing whitespace trimmed - /// (e.g. `python` or `class="brush: python"`). vimwiki inserts this as - /// raw attributes on the `
` tag, so we preserve it for byte-parity.
-    pub attrs: String,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct MathBlockNode {
-    pub span: Span,
-    pub content: String,
-    pub environment: Option,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ListNode {
-    pub span: Span,
-    pub ordered: bool,
-    pub symbol: ListSymbol,
-    pub items: Vec,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ListItemNode {
-    pub span: Span,
-    pub symbol: ListSymbol,
-    pub level: usize,
-    pub checkbox: Option,
-    pub children: Vec,
-    pub sublist: Option,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-pub enum ListSymbol {
-    Dash,
-    Star,
-    Hash,
-    Numeric,
-    NumericParen,
-    AlphaParen,
-    AlphaUpperParen,
-    RomanParen,
-    RomanUpperParen,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-pub enum CheckboxState {
-    Empty,
-    Quarter,
-    Half,
-    ThreeQuarters,
-    Done,
-    Rejected,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct DefinitionListNode {
-    pub span: Span,
-    pub items: Vec,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct DefinitionItemNode {
-    pub span: Span,
-    pub term: Option>,
-    pub definitions: Vec>,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum TableAlign {
-    Default,
-    Left,
-    Right,
-    Center,
-}
-
-impl Default for TableAlign {
-    fn default() -> Self {
-        Self::Default
-    }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct TableNode {
-    pub span: Span,
-    pub rows: Vec,
-    pub has_header: bool,
-    /// One entry per column, taken from a Markdown-style header
-    /// separator row (`|:--|--:|:--:|`). Empty when no alignment was
-    /// specified; cells past the end inherit `Default`.
-    pub alignments: Vec,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct TableRowNode {
-    pub span: Span,
-    pub cells: Vec,
-    pub is_header: bool,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct TableCellNode {
-    pub span: Span,
-    pub children: Vec,
-    pub col_span: bool,
-    pub row_span: bool,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct CommentNode {
-    pub span: Span,
-    pub content: String,
-}
-
-/// Where a `TagNode` falls on the page, matching vimwiki's placement rules.
-///
-/// - `File`        — line 0 or 1 of the document; tags the entire page.
-/// - `Heading(i)`  — within two lines below the i-th `HeadingNode` in the
-///                   document's `children`; tags that heading.
-/// - `Standalone`  — anywhere else; the tag is its own anchor on the source
-///                   line.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-pub enum TagScope {
-    File,
-    Heading(usize),
-    Standalone,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct TagNode {
-    pub span: Span,
-    pub tags: Vec,
-    pub scope: TagScope,
-}
-
-/// Resilient parse-failure node — emitted instead of aborting the document.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ErrorNode {
-    pub span: Span,
-    pub raw: String,
-    pub message: String,
-}
diff --git a/crates/nuwiki-core/src/ast/inline.rs b/crates/nuwiki-core/src/ast/inline.rs
deleted file mode 100644
index 6b37e52..0000000
--- a/crates/nuwiki-core/src/ast/inline.rs
+++ /dev/null
@@ -1,205 +0,0 @@
-//! Inline AST nodes.
-//!
-//! The link-related variants (`WikiLink`, `ExternalLink`,
-//! `Transclusion`, `RawUrl`) wrap structs defined in `super::link`.
-
-use super::link::{ExternalLinkNode, RawUrlNode, TransclusionNode, WikiLinkNode};
-use super::span::Span;
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-pub enum Keyword {
-    Todo,
-    Done,
-    Started,
-    Fixme,
-    Fixed,
-    Xxx,
-    Stopped,
-}
-
-impl Keyword {
-    /// The literal source text of the keyword (e.g. `"TODO"`). Used both for
-    /// the rendered span and for anchor/id derivation via [`inline_text`].
-    pub fn label(self) -> &'static str {
-        match self {
-            Keyword::Todo => "TODO",
-            Keyword::Done => "DONE",
-            Keyword::Started => "STARTED",
-            Keyword::Fixme => "FIXME",
-            Keyword::Fixed => "FIXED",
-            Keyword::Xxx => "XXX",
-            Keyword::Stopped => "STOPPED",
-        }
-    }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum InlineNode {
-    Text(TextNode),
-    Bold(BoldNode),
-    Italic(ItalicNode),
-    BoldItalic(BoldItalicNode),
-    Strikethrough(StrikethroughNode),
-    Code(CodeNode),
-    Superscript(SuperscriptNode),
-    Subscript(SubscriptNode),
-    MathInline(MathInlineNode),
-    Keyword(KeywordNode),
-    Color(ColorNode),
-    WikiLink(WikiLinkNode),
-    ExternalLink(ExternalLinkNode),
-    Transclusion(TransclusionNode),
-    RawUrl(RawUrlNode),
-    /// A soft line break joining two content lines within a paragraph or
-    /// list item. Consumers treat it as whitespace; the HTML renderer emits
-    /// a space (vimwiki `*_ignore_newline = 1`, the default) or `
` - /// (`= 0`). - SoftBreak(SoftBreakNode), -} - -impl InlineNode { - /// The source [`Span`] this node covers. Every variant wraps a node with - /// its own `span` field; this is the one place that maps variant → span. - pub fn span(&self) -> Span { - match self { - InlineNode::Text(n) => n.span, - InlineNode::Bold(n) => n.span, - InlineNode::Italic(n) => n.span, - InlineNode::BoldItalic(n) => n.span, - InlineNode::Strikethrough(n) => n.span, - InlineNode::Code(n) => n.span, - InlineNode::Superscript(n) => n.span, - InlineNode::Subscript(n) => n.span, - InlineNode::MathInline(n) => n.span, - InlineNode::Keyword(n) => n.span, - InlineNode::Color(n) => n.span, - InlineNode::WikiLink(n) => n.span, - InlineNode::ExternalLink(n) => n.span, - InlineNode::Transclusion(n) => n.span, - InlineNode::RawUrl(n) => n.span, - InlineNode::SoftBreak(n) => n.span, - } - } -} - -/// Concatenate the plain-text content of a run of inlines, descending into -/// formatting containers. Links/external links use their description, falling -/// back to the target path / URL. This is the canonical heading/anchor text — -/// the HTML renderer uses it for heading `id`s and the LSP for anchor matching, -/// so the two never drift. -pub fn inline_text(inlines: &[InlineNode]) -> String { - let mut out = String::new(); - push_inline_text(inlines, &mut out); - out.trim().to_string() -} - -fn push_inline_text(inlines: &[InlineNode], out: &mut String) { - for n in inlines { - match n { - InlineNode::Text(t) => out.push_str(&t.content), - InlineNode::Bold(b) => push_inline_text(&b.children, out), - InlineNode::Italic(i) => push_inline_text(&i.children, out), - InlineNode::BoldItalic(bi) => push_inline_text(&bi.children, out), - InlineNode::Strikethrough(s) => push_inline_text(&s.children, out), - InlineNode::Code(c) => out.push_str(&c.content), - InlineNode::Superscript(s) => push_inline_text(&s.children, out), - InlineNode::Subscript(s) => push_inline_text(&s.children, out), - InlineNode::Color(c) => push_inline_text(&c.children, out), - InlineNode::Keyword(k) => out.push_str(k.keyword.label()), - InlineNode::WikiLink(w) => match &w.description { - Some(d) => push_inline_text(d, out), - None => { - if let Some(p) = &w.target.path { - out.push_str(p); - } - } - }, - InlineNode::ExternalLink(e) => match &e.description { - Some(d) => push_inline_text(d, out), - None => out.push_str(&e.url), - }, - _ => {} - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TextNode { - pub span: Span, - pub content: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SoftBreakNode { - pub span: Span, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BoldNode { - pub span: Span, - pub children: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ItalicNode { - pub span: Span, - pub children: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BoldItalicNode { - pub span: Span, - pub children: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct StrikethroughNode { - pub span: Span, - pub children: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CodeNode { - pub span: Span, - pub content: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SuperscriptNode { - pub span: Span, - pub children: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SubscriptNode { - pub span: Span, - pub children: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MathInlineNode { - pub span: Span, - pub content: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct KeywordNode { - pub span: Span, - pub keyword: Keyword, -} - -/// A named colour span (`color` is a `color_dic` key, `children` the wrapped -/// inline content). -/// -/// This is an **extension point**, not a node the vimwiki parser emits: in -/// the editor, colour comes from the `:Colorize` family writing literal -/// `` markup, which round-trips through export via -/// `valid_html_tags`. `ColorNode` exists so an alternate syntax (or a future -/// `[color]` markup) can feed the renderer's `color_dic` / `color_tag_template` -/// path directly; see `HtmlRenderer::render_color`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ColorNode { - pub span: Span, - pub color: String, - pub children: Vec, -} diff --git a/crates/nuwiki-core/src/ast/link.rs b/crates/nuwiki-core/src/ast/link.rs deleted file mode 100644 index e3b230c..0000000 --- a/crates/nuwiki-core/src/ast/link.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! Link-related AST nodes and supporting types. -//! -//! All nodes here are inline (`InlineNode` variants); -//! they live in their own module because the link model has enough surface -//! area (kinds, anchors, interwiki indirection) to warrant separation. - -use std::collections::HashMap; - -use super::inline::InlineNode; -use super::span::Span; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum LinkKind { - Wiki, - Interwiki, - Diary, - File, - Local, - Raw, - AnchorOnly, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] -pub struct LinkTarget { - pub kind: LinkKind, - pub path: Option, - pub wiki_index: Option, - pub wiki_name: Option, - pub anchor: Option, - pub is_absolute: bool, - pub is_directory: bool, -} - -impl Default for LinkKind { - fn default() -> Self { - Self::Wiki - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WikiLinkNode { - pub span: Span, - pub target: LinkTarget, - pub description: Option>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExternalLinkNode { - pub span: Span, - pub url: String, - pub description: Option>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TransclusionNode { - pub span: Span, - pub url: String, - pub alt: Option, - pub attrs: HashMap, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RawUrlNode { - pub span: Span, - pub url: String, -} diff --git a/crates/nuwiki-core/src/ast/mod.rs b/crates/nuwiki-core/src/ast/mod.rs deleted file mode 100644 index 69c070a..0000000 --- a/crates/nuwiki-core/src/ast/mod.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! AST types for nuwiki documents. -//! -//! Types here are syntax-agnostic — both vimwiki and (eventually) markdown -//! produce the same node shapes. - -pub mod block; -pub mod inline; -pub mod link; -pub mod span; -pub mod visit; - -pub use block::{ - BlockNode, BlockquoteNode, CheckboxState, CommentNode, DefinitionItemNode, DefinitionListNode, - ErrorNode, HeadingNode, HorizontalRuleNode, ListItemNode, ListNode, ListSymbol, MathBlockNode, - ParagraphNode, PreformattedNode, TableAlign, TableCellNode, TableNode, TableRowNode, TagNode, - TagScope, -}; -pub use inline::{ - inline_text, BoldItalicNode, BoldNode, CodeNode, ColorNode, InlineNode, ItalicNode, Keyword, - KeywordNode, MathInlineNode, SoftBreakNode, StrikethroughNode, SubscriptNode, SuperscriptNode, - TextNode, -}; -pub use link::{ - ExternalLinkNode, LinkKind, LinkTarget, RawUrlNode, TransclusionNode, WikiLinkNode, -}; -pub use span::{Position, Span}; -pub use visit::{ - walk_block, walk_definition_item, walk_document, walk_inline, walk_list, walk_list_item, - walk_table, walk_table_row, Visitor, -}; - -/// Root of an AST. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct DocumentNode { - pub span: Span, - pub children: Vec, - pub metadata: PageMetadata, -} - -/// Document-level placeholders parsed from `%title` / `%nohtml` / `%template` -/// / `%date` directives, plus the file-level tag accumulator. -/// -/// New fields are added at the bottom; consumers should construct with -/// `..Default::default()` to stay forward-compatible. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct PageMetadata { - pub title: Option, - pub nohtml: bool, - pub template: Option, - pub date: Option, - /// File-scope tags parsed by the tag lexer. Convenience - /// projection of the `BlockNode::Tag` nodes whose scope is `File`. - pub tags: Vec, -} diff --git a/crates/nuwiki-core/src/ast/span.rs b/crates/nuwiki-core/src/ast/span.rs deleted file mode 100644 index aefc11c..0000000 --- a/crates/nuwiki-core/src/ast/span.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! Source positions and spans carried on every AST node. -//! -//! Positions are 0-indexed; `column` is a byte offset -//! within a line; `offset` is a byte offset from the start of the document. - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct Position { - pub line: u32, - pub column: u32, - pub offset: usize, -} - -impl Position { - pub const fn new(line: u32, column: u32, offset: usize) -> Self { - Self { - line, - column, - offset, - } - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] -pub struct Span { - pub start: Position, - pub end: Position, -} - -impl Span { - pub const fn new(start: Position, end: Position) -> Self { - Self { start, end } - } -} diff --git a/crates/nuwiki-core/src/ast/visit.rs b/crates/nuwiki-core/src/ast/visit.rs deleted file mode 100644 index 8471398..0000000 --- a/crates/nuwiki-core/src/ast/visit.rs +++ /dev/null @@ -1,230 +0,0 @@ -//! Open-recursion AST visitor. -//! -//! Each `visit_*` method has a default body that calls the corresponding -//! `walk_*` free function, which in turn dispatches back into the trait. -//! Override any method to short-circuit or augment traversal — call the -//! matching `walk_*` from your override to keep descending. -//! -//! Pattern follows `rustc`'s and `syn`'s visitors. -//! -//! Read-only for now. A `VisitorMut` flavor can be added when transformations -//! become relevant. - -use super::block::{ - BlockNode, BlockquoteNode, CommentNode, DefinitionItemNode, DefinitionListNode, ErrorNode, - HeadingNode, HorizontalRuleNode, ListItemNode, ListNode, MathBlockNode, ParagraphNode, - PreformattedNode, TableCellNode, TableNode, TableRowNode, TagNode, -}; -use super::inline::{ - BoldItalicNode, BoldNode, CodeNode, ColorNode, InlineNode, ItalicNode, KeywordNode, - MathInlineNode, StrikethroughNode, SubscriptNode, SuperscriptNode, TextNode, -}; -use super::link::{ExternalLinkNode, RawUrlNode, TransclusionNode, WikiLinkNode}; -use super::DocumentNode; - -pub trait Visitor { - // Top level - fn visit_document(&mut self, node: &DocumentNode) { - walk_document(self, node); - } - - // Block dispatch - fn visit_block(&mut self, node: &BlockNode) { - walk_block(self, node); - } - - // Inline dispatch - fn visit_inline(&mut self, node: &InlineNode) { - walk_inline(self, node); - } - - // Block leaves and recursive block nodes - fn visit_heading(&mut self, node: &HeadingNode) { - for child in &node.children { - self.visit_inline(child); - } - } - fn visit_paragraph(&mut self, node: &ParagraphNode) { - for child in &node.children { - self.visit_inline(child); - } - } - fn visit_horizontal_rule(&mut self, _node: &HorizontalRuleNode) {} - fn visit_blockquote(&mut self, node: &BlockquoteNode) { - for child in &node.children { - self.visit_block(child); - } - } - fn visit_preformatted(&mut self, _node: &PreformattedNode) {} - fn visit_math_block(&mut self, _node: &MathBlockNode) {} - fn visit_list(&mut self, node: &ListNode) { - walk_list(self, node); - } - fn visit_list_item(&mut self, node: &ListItemNode) { - walk_list_item(self, node); - } - fn visit_definition_list(&mut self, node: &DefinitionListNode) { - for item in &node.items { - self.visit_definition_item(item); - } - } - fn visit_definition_item(&mut self, node: &DefinitionItemNode) { - walk_definition_item(self, node); - } - fn visit_table(&mut self, node: &TableNode) { - walk_table(self, node); - } - fn visit_table_row(&mut self, node: &TableRowNode) { - walk_table_row(self, node); - } - fn visit_table_cell(&mut self, node: &TableCellNode) { - for child in &node.children { - self.visit_inline(child); - } - } - fn visit_comment(&mut self, _node: &CommentNode) {} - fn visit_tag(&mut self, _node: &TagNode) {} - fn visit_error(&mut self, _node: &ErrorNode) {} - - // Inline leaves and recursive inline nodes - fn visit_text(&mut self, _node: &TextNode) {} - fn visit_bold(&mut self, node: &BoldNode) { - for child in &node.children { - self.visit_inline(child); - } - } - fn visit_italic(&mut self, node: &ItalicNode) { - for child in &node.children { - self.visit_inline(child); - } - } - fn visit_bold_italic(&mut self, node: &BoldItalicNode) { - for child in &node.children { - self.visit_inline(child); - } - } - fn visit_strikethrough(&mut self, node: &StrikethroughNode) { - for child in &node.children { - self.visit_inline(child); - } - } - fn visit_code(&mut self, _node: &CodeNode) {} - fn visit_superscript(&mut self, node: &SuperscriptNode) { - for child in &node.children { - self.visit_inline(child); - } - } - fn visit_subscript(&mut self, node: &SubscriptNode) { - for child in &node.children { - self.visit_inline(child); - } - } - fn visit_math_inline(&mut self, _node: &MathInlineNode) {} - fn visit_keyword(&mut self, _node: &KeywordNode) {} - fn visit_color(&mut self, node: &ColorNode) { - for child in &node.children { - self.visit_inline(child); - } - } - fn visit_wiki_link(&mut self, node: &WikiLinkNode) { - if let Some(desc) = &node.description { - for child in desc { - self.visit_inline(child); - } - } - } - fn visit_external_link(&mut self, node: &ExternalLinkNode) { - if let Some(desc) = &node.description { - for child in desc { - self.visit_inline(child); - } - } - } - fn visit_transclusion(&mut self, _node: &TransclusionNode) {} - fn visit_raw_url(&mut self, _node: &RawUrlNode) {} -} - -pub fn walk_document(v: &mut V, node: &DocumentNode) { - for child in &node.children { - v.visit_block(child); - } -} - -pub fn walk_block(v: &mut V, node: &BlockNode) { - match node { - BlockNode::Heading(n) => v.visit_heading(n), - BlockNode::Paragraph(n) => v.visit_paragraph(n), - BlockNode::HorizontalRule(n) => v.visit_horizontal_rule(n), - BlockNode::Blockquote(n) => v.visit_blockquote(n), - BlockNode::Preformatted(n) => v.visit_preformatted(n), - BlockNode::MathBlock(n) => v.visit_math_block(n), - BlockNode::List(n) => v.visit_list(n), - BlockNode::DefinitionList(n) => v.visit_definition_list(n), - BlockNode::Table(n) => v.visit_table(n), - BlockNode::Comment(n) => v.visit_comment(n), - BlockNode::Tag(n) => v.visit_tag(n), - BlockNode::Error(n) => v.visit_error(n), - } -} - -pub fn walk_inline(v: &mut V, node: &InlineNode) { - match node { - InlineNode::Text(n) => v.visit_text(n), - InlineNode::Bold(n) => v.visit_bold(n), - InlineNode::Italic(n) => v.visit_italic(n), - InlineNode::BoldItalic(n) => v.visit_bold_italic(n), - InlineNode::Strikethrough(n) => v.visit_strikethrough(n), - InlineNode::Code(n) => v.visit_code(n), - InlineNode::Superscript(n) => v.visit_superscript(n), - InlineNode::Subscript(n) => v.visit_subscript(n), - InlineNode::MathInline(n) => v.visit_math_inline(n), - InlineNode::Keyword(n) => v.visit_keyword(n), - InlineNode::Color(n) => v.visit_color(n), - InlineNode::WikiLink(n) => v.visit_wiki_link(n), - InlineNode::ExternalLink(n) => v.visit_external_link(n), - InlineNode::Transclusion(n) => v.visit_transclusion(n), - InlineNode::RawUrl(n) => v.visit_raw_url(n), - // A soft break carries no content/children — treated as whitespace. - InlineNode::SoftBreak(_) => {} - } -} - -pub fn walk_list(v: &mut V, node: &ListNode) { - for item in &node.items { - v.visit_list_item(item); - } -} - -pub fn walk_list_item(v: &mut V, node: &ListItemNode) { - for child in &node.children { - v.visit_inline(child); - } - if let Some(sublist) = &node.sublist { - v.visit_list(sublist); - } -} - -pub fn walk_definition_item(v: &mut V, node: &DefinitionItemNode) { - if let Some(term) = &node.term { - for child in term { - v.visit_inline(child); - } - } - for definition in &node.definitions { - for child in definition { - v.visit_inline(child); - } - } -} - -pub fn walk_table(v: &mut V, node: &TableNode) { - for row in &node.rows { - v.visit_table_row(row); - } -} - -pub fn walk_table_row(v: &mut V, node: &TableRowNode) { - for cell in &node.cells { - v.visit_table_cell(cell); - } -} diff --git a/crates/nuwiki-core/src/date.rs b/crates/nuwiki-core/src/date.rs deleted file mode 100644 index ef5e29a..0000000 --- a/crates/nuwiki-core/src/date.rs +++ /dev/null @@ -1,541 +0,0 @@ -//! Date primitives for the diary subsystem. -//! -//! This crate deliberately doesn't pull in `chrono` or `time`. The diary feature -//! only needs `YYYY-MM-DD` parsing/formatting and ±1 day arithmetic, which -//! is small enough that the dependency cost outweighs the convenience. -//! -//! Date math uses Howard Hinnant's days-from-civil algorithm -//! () — proleptic -//! Gregorian, valid for any year representable in `i32`. - -use std::cmp::Ordering; -use std::fmt; -use std::time::{SystemTime, UNIX_EPOCH}; - -/// A calendar day, decoupled from any time zone or wall-clock context. -/// -/// Validation: `parse` and `from_ymd` reject impossible dates (month 0, -/// day 0, day > days-in-month, etc.). The struct fields are public so -/// callers can read them, but constructing one with bogus values bypasses -/// validation — use the constructors. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct DiaryDate { - pub year: i32, - pub month: u8, - pub day: u8, -} - -impl DiaryDate { - /// Build a date from its components, validating the calendar. - pub fn from_ymd(year: i32, month: u8, day: u8) -> Option { - if !(1..=12).contains(&month) { - return None; - } - if day < 1 || day > days_in_month(year, month) { - return None; - } - Some(Self { year, month, day }) - } - - /// Parse a strict `YYYY-MM-DD` string. Rejects any deviation — extra - /// whitespace, missing zero-padding, alternate separators, etc. - pub fn parse(s: &str) -> Option { - let b = s.as_bytes(); - if b.len() != 10 || b[4] != b'-' || b[7] != b'-' { - return None; - } - let y = parse_digits(&b[0..4])?; - let m = parse_digits(&b[5..7])? as u8; - let d = parse_digits(&b[8..10])? as u8; - Self::from_ymd(y as i32, m, d) - } - - /// Format as `YYYY-MM-DD`. Years outside 0..=9999 still render - /// numerically; the diary use case never produces those. - pub fn format(&self) -> String { - format!("{:04}-{:02}-{:02}", self.year, self.month, self.day) - } - - /// UTC "today" computed from `SystemTime::now()`. The diary - /// commands intentionally use UTC — local-time semantics depend on a - /// timezone DB we don't ship and would surprise users crossing DST - /// boundaries. - pub fn today_utc() -> Self { - let secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let days = (secs / 86_400) as i64; - from_days(days) - } - - pub fn next_day(&self) -> Self { - from_days(to_days(self.year, self.month, self.day) + 1) - } - - pub fn prev_day(&self) -> Self { - from_days(to_days(self.year, self.month, self.day) - 1) - } - - /// Internal — exposed for tests that want to verify ordering against - /// the days-from-epoch representation. - pub fn to_days_epoch(&self) -> i64 { - to_days(self.year, self.month, self.day) - } -} - -impl PartialOrd for DiaryDate { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for DiaryDate { - fn cmp(&self, other: &Self) -> Ordering { - self.to_days_epoch().cmp(&other.to_days_epoch()) - } -} - -impl fmt::Display for DiaryDate { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.format()) - } -} - -fn parse_digits(b: &[u8]) -> Option { - if b.is_empty() { - return None; - } - let mut n: u32 = 0; - for ch in b { - if !ch.is_ascii_digit() { - return None; - } - n = n.checked_mul(10)?.checked_add((*ch - b'0') as u32)?; - } - Some(n) -} - -fn is_leap(year: i32) -> bool { - (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) -} - -fn days_in_month(year: i32, month: u8) -> u8 { - match month { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 => { - if is_leap(year) { - 29 - } else { - 28 - } - } - _ => 0, - } -} - -/// Howard Hinnant's `days_from_civil`: 1970-01-01 → 0. -fn to_days(year: i32, month: u8, day: u8) -> i64 { - let y = year as i64 - if (month as i64) <= 2 { 1 } else { 0 }; - let era = y.div_euclid(400); - let yoe = y.rem_euclid(400); - let m = month as i64; - let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day as i64 - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146_097 + doe - 719_468 -} - -// ============================================================ -// Diary frequency / period -// ============================================================ -// -// `DiaryFrequency` is the user-configured cadence (mirrors vimwiki's -// `diary_frequency` setting). `DiaryPeriod` is the *concrete* diary -// entry — a specific day, ISO week, calendar month, or year — that -// the LSP commands compute and the renderer addresses. - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum DiaryFrequency { - Daily, - Weekly, - Monthly, - Yearly, -} - -impl DiaryFrequency { - /// Parse the string form used in `WikiConfig.diary_frequency`. - /// Falls back to `Daily` for unrecognised values — vimwiki's - /// permissive behaviour for stale configs. - pub fn parse(s: &str) -> Self { - match s.trim().to_ascii_lowercase().as_str() { - "weekly" | "week" => Self::Weekly, - "monthly" | "month" => Self::Monthly, - "yearly" | "year" => Self::Yearly, - _ => Self::Daily, - } - } - - pub fn as_str(self) -> &'static str { - match self { - Self::Daily => "daily", - Self::Weekly => "weekly", - Self::Monthly => "monthly", - Self::Yearly => "yearly", - } - } -} - -/// A specific diary entry, identified by its calendar period. The four -/// variants share the same `format` / `parse` / `next` / `prev` / -/// `today_utc` surface so the LSP dispatcher can stay frequency-agnostic. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum DiaryPeriod { - Day(DiaryDate), - /// ISO 8601 week — year/week pair, week ∈ 1..=53. - Week { - iso_year: i32, - iso_week: u8, - }, - Month { - year: i32, - month: u8, - }, - Year { - year: i32, - }, -} - -impl DiaryPeriod { - /// "Now" for the given frequency, in UTC. See `DiaryDate::today_utc` - /// for the timezone rationale. - pub fn today_utc(freq: DiaryFrequency) -> Self { - let today = DiaryDate::today_utc(); - match freq { - DiaryFrequency::Daily => Self::Day(today), - DiaryFrequency::Weekly => Self::week_containing(today), - DiaryFrequency::Monthly => Self::Month { - year: today.year, - month: today.month, - }, - DiaryFrequency::Yearly => Self::Year { year: today.year }, - } - } - - /// File-stem format — also the format vimwiki uses for diary link - /// targets (`[[diary:2026-W19]]`, `[[diary:2026-05]]`, etc.). - /// - /// Day(2026-05-12) → `2026-05-12` - /// Week(2026, 19) → `2026-W19` - /// Month(2026, 5) → `2026-05` - /// Year(2026) → `2026` - pub fn format(&self) -> String { - match self { - Self::Day(d) => d.format(), - Self::Week { iso_year, iso_week } => format!("{iso_year:04}-W{iso_week:02}"), - Self::Month { year, month } => format!("{year:04}-{month:02}"), - Self::Year { year } => format!("{year:04}"), - } - } - - /// Inverse of `format` — recognise any of the four stems. Returns - /// `None` for inputs that don't match any flavour. - pub fn parse(s: &str) -> Option { - // Day: `YYYY-MM-DD` - if let Some(d) = DiaryDate::parse(s) { - return Some(Self::Day(d)); - } - let b = s.as_bytes(); - // Week: `YYYY-Www` - if b.len() == 8 && b[4] == b'-' && (b[5] == b'W' || b[5] == b'w') { - let y = parse_digits(&b[0..4])? as i32; - let w = parse_digits(&b[6..8])? as u8; - if (1..=53).contains(&w) { - return Some(Self::Week { - iso_year: y, - iso_week: w, - }); - } - } - // Month: `YYYY-MM` - if b.len() == 7 && b[4] == b'-' { - let y = parse_digits(&b[0..4])? as i32; - let m = parse_digits(&b[5..7])? as u8; - if (1..=12).contains(&m) { - return Some(Self::Month { year: y, month: m }); - } - } - // Year: `YYYY` - if b.len() == 4 { - let y = parse_digits(&b[0..4])? as i32; - return Some(Self::Year { year: y }); - } - None - } - - pub fn next(&self) -> Self { - match *self { - Self::Day(d) => Self::Day(d.next_day()), - Self::Week { iso_year, iso_week } => { - // Step forward one week: take the Monday of this ISO week, - // add 7 days, recompute the (year, week) it falls in. - let mon = monday_of_iso_week(iso_year, iso_week); - let next_mon = from_days(to_days(mon.year, mon.month, mon.day) + 7); - let (iso_year, iso_week) = iso_year_week(&next_mon); - Self::Week { iso_year, iso_week } - } - Self::Month { year, month } => { - if month == 12 { - Self::Month { - year: year + 1, - month: 1, - } - } else { - Self::Month { - year, - month: month + 1, - } - } - } - Self::Year { year } => Self::Year { year: year + 1 }, - } - } - - pub fn prev(&self) -> Self { - match *self { - Self::Day(d) => Self::Day(d.prev_day()), - Self::Week { iso_year, iso_week } => { - let mon = monday_of_iso_week(iso_year, iso_week); - let prev_mon = from_days(to_days(mon.year, mon.month, mon.day) - 7); - let (iso_year, iso_week) = iso_year_week(&prev_mon); - Self::Week { iso_year, iso_week } - } - Self::Month { year, month } => { - if month == 1 { - Self::Month { - year: year - 1, - month: 12, - } - } else { - Self::Month { - year, - month: month - 1, - } - } - } - Self::Year { year } => Self::Year { year: year - 1 }, - } - } - - /// Compute the ISO week containing the given calendar date. - pub fn week_containing(d: DiaryDate) -> Self { - let (iso_year, iso_week) = iso_year_week(&d); - Self::Week { iso_year, iso_week } - } - - /// First calendar day of this period — useful for chronological - /// sorting across mixed-frequency entries. - /// - /// Day(d) → d - /// Week(y,w) → Monday of that ISO week - /// Month(y,m) → 1st of that month - /// Year(y) → Jan 1st of that year - pub fn first_day(&self) -> DiaryDate { - match *self { - Self::Day(d) => d, - Self::Week { iso_year, iso_week } => monday_of_iso_week(iso_year, iso_week), - Self::Month { year, month } => { - DiaryDate::from_ymd(year, month, 1).expect("validated month") - } - Self::Year { year } => DiaryDate::from_ymd(year, 1, 1).expect("Jan 1st always valid"), - } - } -} - -impl fmt::Display for DiaryPeriod { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.format()) - } -} - -/// How a *weekly* diary entry is named. -/// -/// `Iso` — ISO-week label `YYYY-Www` (nuwiki's original scheme; the week -/// always begins on Monday, `week_start` is ignored). -/// `Date` — the week-start day's calendar date `YYYY-MM-DD`, matching -/// upstream vimwiki; honours `week_start`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum WeeklyStyle { - Iso, - Date, -} - -impl WeeklyStyle { - /// Parse the config string. `date`/`vimwiki` → `Date`; anything else - /// (including `iso`/`week`/empty) → `Iso`. - pub fn parse(s: &str) -> Self { - match s.trim().to_ascii_lowercase().as_str() { - "date" | "vimwiki" | "day" => Self::Date, - _ => Self::Iso, - } - } -} - -/// Day a week begins on, in ISO numbering (Monday = 1 … Sunday = 7). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct WeekStart(u8); - -impl WeekStart { - /// Parse a weekday name (`monday`..`sunday`); unknown → Monday. - pub fn parse(s: &str) -> Self { - let n = match s.trim().to_ascii_lowercase().as_str() { - "tuesday" => 2, - "wednesday" => 3, - "thursday" => 4, - "friday" => 5, - "saturday" => 6, - "sunday" => 7, - _ => 1, // monday (default) - }; - Self(n) - } - - pub fn monday() -> Self { - Self(1) - } - - fn iso(self) -> i64 { - self.0 as i64 - } -} - -/// Diary navigation policy: frequency plus, for weekly diaries, the naming -/// scheme and week-start day. Built from a wiki's `diary_*` config so the -/// LSP commands can compute today / next / prev without re-deriving the -/// rules. `Daily`/`Monthly`/`Yearly` ignore the weekly fields. -#[derive(Debug, Clone, Copy)] -pub struct DiaryCalendar { - pub freq: DiaryFrequency, - pub weekly_style: WeeklyStyle, - pub week_start: WeekStart, -} - -impl DiaryCalendar { - pub fn new(freq: DiaryFrequency, weekly_style: WeeklyStyle, week_start: WeekStart) -> Self { - Self { - freq, - weekly_style, - week_start, - } - } - - fn is_weekly_date(&self) -> bool { - self.freq == DiaryFrequency::Weekly && self.weekly_style == WeeklyStyle::Date - } - - /// "Now" as a diary period for this calendar. Date-mode weekly snaps - /// today back to the most recent week-start day (a plain `Day`, so the - /// file is `YYYY-MM-DD` like upstream); everything else defers to - /// [`DiaryPeriod::today_utc`]. - pub fn today(&self) -> DiaryPeriod { - if self.is_weekly_date() { - DiaryPeriod::Day(self.week_start_of(DiaryDate::today_utc())) - } else { - DiaryPeriod::today_utc(self.freq) - } - } - - /// The period one cadence-step after `p`. - pub fn next(&self, p: DiaryPeriod) -> DiaryPeriod { - self.step(p, 1) - } - - /// The period one cadence-step before `p`. - pub fn prev(&self, p: DiaryPeriod) -> DiaryPeriod { - self.step(p, -1) - } - - fn step(&self, p: DiaryPeriod, dir: i64) -> DiaryPeriod { - // Date-mode weekly: the period is a `Day` on a week-start; a step is - // ±7 days (re-snapped so an off-week-start pivot still lands cleanly). - if self.is_weekly_date() { - if let DiaryPeriod::Day(d) = p { - let start = self.week_start_of(d); - return DiaryPeriod::Day(add_days(start, 7 * dir)); - } - } - if dir >= 0 { - p.next() - } else { - p.prev() - } - } - - /// The most recent `week_start` weekday on or before `d`. - fn week_start_of(&self, d: DiaryDate) -> DiaryDate { - let wd = iso_weekday(&d) as i64; // 1..=7 - let back = (wd - self.week_start.iso()).rem_euclid(7); - add_days(d, -back) - } -} - -/// Shift a calendar date by `delta` days (may be negative). -fn add_days(d: DiaryDate, delta: i64) -> DiaryDate { - from_days(to_days(d.year, d.month, d.day) + delta) -} - -/// Day-of-week with Monday=1 … Sunday=7 (ISO 8601). -fn iso_weekday(d: &DiaryDate) -> u8 { - // Zeller-style via days-from-epoch: 1970-01-01 was a Thursday. - let days = to_days(d.year, d.month, d.day); - // (days + 3) mod 7 puts Monday at 0 … Sunday at 6; shift to 1..=7. - let r = (days + 3).rem_euclid(7); - (r as u8) + 1 -} - -/// ISO 8601 `(year, week)` for a calendar date. The ISO year may differ -/// from the calendar year for early January / late December dates. -fn iso_year_week(d: &DiaryDate) -> (i32, u8) { - // Standard algorithm: shift to the Thursday of the same ISO week, - // then ISO year = thursday.year, ISO week = ordinal_day(thursday) / 7 + 1. - let weekday = iso_weekday(d) as i64; // 1..=7 - let days = to_days(d.year, d.month, d.day); - let thursday = from_days(days + 4 - weekday); - let jan1 = to_days(thursday.year, 1, 1); - let ordinal = to_days(thursday.year, thursday.month, thursday.day) - jan1 + 1; - let week = ((ordinal - 1) / 7 + 1) as u8; - (thursday.year, week) -} - -/// Date of the Monday that opens the given ISO `(year, week)`. -fn monday_of_iso_week(iso_year: i32, iso_week: u8) -> DiaryDate { - // Find Jan 4th of iso_year — always in ISO week 1 by definition. - let jan4 = DiaryDate { - year: iso_year, - month: 1, - day: 4, - }; - let jan4_weekday = iso_weekday(&jan4) as i64; // 1..=7 - let week1_monday_days = to_days(jan4.year, jan4.month, jan4.day) - (jan4_weekday - 1); - let target = week1_monday_days + (iso_week as i64 - 1) * 7; - from_days(target) -} - -/// Inverse of [`to_days`]. -fn from_days(days: i64) -> DiaryDate { - let z = days + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as u64; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u8; - let m_int = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = y + if m_int <= 2 { 1 } else { 0 }; - DiaryDate { - year: year as i32, - month: m_int as u8, - day: d, - } -} diff --git a/crates/nuwiki-core/src/lib.rs b/crates/nuwiki-core/src/lib.rs deleted file mode 100644 index a032210..0000000 --- a/crates/nuwiki-core/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Core parser, AST, and renderer for nuwiki. -//! -//! This crate is editor-independent and must never depend on `nuwiki-lsp` or -//! `nuwiki-ls`. - -pub mod ast; -pub mod date; -pub mod listsyms; -pub mod render; -pub mod syntax; diff --git a/crates/nuwiki-core/src/listsyms.rs b/crates/nuwiki-core/src/listsyms.rs deleted file mode 100644 index 0186480..0000000 --- a/crates/nuwiki-core/src/listsyms.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Configurable checkbox progress palette (vimwiki's `g:vimwiki_listsyms`). -//! -//! A palette is a progression of glyphs from "empty" (index 0) to "done" -//! (the last index). The default `" .oOX"` is the stock vimwiki palette. -//! A separate `rejected` glyph (`-`) is fixed by convention. -//! -//! The palette maps glyphs to a normalised five-bucket [`CheckboxState`] for -//! the AST (so the HTML renderer keeps its fixed `done0..done4` classes) while -//! still letting the LSP cycle/toggle commands operate on the real glyphs. - -use crate::ast::CheckboxState; - -/// Default rejected/cancelled marker (vimwiki's `listsym_rejected`). -const REJECTED: char = '-'; - -/// An ordered checkbox progress palette. Always holds at least two glyphs. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ListSyms { - progression: Vec, - rejected: char, -} - -impl Default for ListSyms { - fn default() -> Self { - Self { - progression: " .oOX".chars().collect(), - rejected: REJECTED, - } - } -} - -impl ListSyms { - /// Build a palette from a glyph string, with the default rejected marker. - /// Falls back to the default when fewer than two glyphs are supplied (a - /// one-symbol palette can't express both "empty" and "done"). - pub fn new(s: &str) -> Self { - Self::new_with_rejected(s, REJECTED) - } - - /// Like [`new`](Self::new) but with a custom rejected/cancelled glyph - /// (vimwiki's `listsym_rejected`). - pub fn new_with_rejected(s: &str, rejected: char) -> Self { - let progression: Vec = s.chars().collect(); - if progression.len() < 2 { - Self { - rejected, - ..Self::default() - } - } else { - Self { - progression, - rejected, - } - } - } - - pub fn len(&self) -> usize { - self.progression.len() - } - - pub fn is_empty(&self) -> bool { - self.progression.is_empty() - } - - pub fn empty_glyph(&self) -> char { - self.progression[0] - } - - pub fn done_glyph(&self) -> char { - *self.progression.last().unwrap() - } - - pub fn rejected_glyph(&self) -> char { - self.rejected - } - - pub fn glyph_at(&self, index: usize) -> char { - self.progression[index.min(self.progression.len() - 1)] - } - - pub fn index_of(&self, c: char) -> Option { - self.progression.iter().position(|&g| g == c) - } - - /// Progress rate (0..=100) for a glyph index. - pub fn rate(&self, index: usize) -> i32 { - let last = (self.progression.len() - 1) as i32; - (index as i32) * 100 / last - } - - /// Normalised five-bucket [`CheckboxState`] for a glyph, or `None` when the - /// glyph is not part of this palette (and is not the rejected marker). - pub fn state_of(&self, c: char) -> Option { - if c == self.rejected { - return Some(CheckboxState::Rejected); - } - let index = self.index_of(c)?; - let rate = self.rate(index); - // Round the rate to the nearest 25% bucket. - Some(match (rate + 12) / 25 { - 0 => CheckboxState::Empty, - 1 => CheckboxState::Quarter, - 2 => CheckboxState::Half, - 3 => CheckboxState::ThreeQuarters, - _ => CheckboxState::Done, - }) - } - - /// Snap a rate (`-1` = rejected, else 0..=100) to a glyph. Intermediate - /// rates distribute over the `n - 2` non-terminal glyphs using vimwiki's - /// `ceil` rule, matching the stock five-symbol behaviour. - pub fn glyph_for_rate(&self, rate: i32) -> char { - if rate < 0 { - return self.rejected; - } - if rate <= 0 { - return self.empty_glyph(); - } - if rate >= 100 { - return self.done_glyph(); - } - let mids = (self.progression.len().saturating_sub(2)).max(1) as f64; - let index = ((rate as f64) / 100.0 * mids).ceil() as usize; - self.glyph_at(index) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_palette_matches_stock_vimwiki() { - let s = ListSyms::default(); - assert_eq!(s.len(), 5); - assert_eq!(s.empty_glyph(), ' '); - assert_eq!(s.done_glyph(), 'X'); - assert_eq!(s.rejected_glyph(), '-'); - assert_eq!(s.rate(0), 0); - assert_eq!(s.rate(1), 25); - assert_eq!(s.rate(2), 50); - assert_eq!(s.rate(3), 75); - assert_eq!(s.rate(4), 100); - } - - #[test] - fn default_state_of_round_trips_each_glyph() { - let s = ListSyms::default(); - assert_eq!(s.state_of(' '), Some(CheckboxState::Empty)); - assert_eq!(s.state_of('.'), Some(CheckboxState::Quarter)); - assert_eq!(s.state_of('o'), Some(CheckboxState::Half)); - assert_eq!(s.state_of('O'), Some(CheckboxState::ThreeQuarters)); - assert_eq!(s.state_of('X'), Some(CheckboxState::Done)); - assert_eq!(s.state_of('-'), Some(CheckboxState::Rejected)); - assert_eq!(s.state_of('?'), None); - } - - #[test] - fn default_glyph_for_rate_matches_legacy_buckets() { - let s = ListSyms::default(); - assert_eq!(s.glyph_for_rate(-1), '-'); - assert_eq!(s.glyph_for_rate(0), ' '); - assert_eq!(s.glyph_for_rate(25), '.'); - assert_eq!(s.glyph_for_rate(50), 'o'); - assert_eq!(s.glyph_for_rate(75), 'O'); - assert_eq!(s.glyph_for_rate(100), 'X'); - } - - #[test] - fn short_input_falls_back_to_default() { - assert_eq!(ListSyms::new(""), ListSyms::default()); - assert_eq!(ListSyms::new("x"), ListSyms::default()); - } - - #[test] - fn custom_rejected_glyph_is_honoured() { - // `listsym_rejected = '✗'`: that glyph lexes/snaps as Rejected and the - // default `-` no longer does. - let s = ListSyms::new_with_rejected(" .oOX", '✗'); - assert_eq!(s.rejected_glyph(), '✗'); - assert_eq!(s.state_of('✗'), Some(CheckboxState::Rejected)); - assert_eq!(s.glyph_for_rate(-1), '✗'); - assert_eq!(s.state_of('-'), None); // no longer the rejected marker - // A short palette still keeps the custom rejected glyph. - assert_eq!(ListSyms::new_with_rejected("x", '✗').rejected_glyph(), '✗'); - } - - #[test] - fn custom_palette_buckets_to_nearest_state() { - // 3 glyphs → rates 0 / 50 / 100. - let s = ListSyms::new(" x✓"); - assert_eq!(s.state_of(' '), Some(CheckboxState::Empty)); - assert_eq!(s.state_of('x'), Some(CheckboxState::Half)); - assert_eq!(s.state_of('✓'), Some(CheckboxState::Done)); - assert_eq!(s.glyph_for_rate(50), 'x'); - assert_eq!(s.glyph_for_rate(100), '✓'); - } -} diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs deleted file mode 100644 index c21d511..0000000 --- a/crates/nuwiki-core/src/render/html.rs +++ /dev/null @@ -1,1232 +0,0 @@ -//! HTML renderer. -//! -//! Walks a `DocumentNode` and writes HTML5 fragments. The renderer is -//! configured with a link resolver (callback that turns a `LinkTarget` into -//! a URL string) and, optionally, an enclosing template with substitution -//! placeholders. -//! -//! Substitution model: `{{content}}` and `{{title}}` are computed from -//! the rendered body and the document's metadata; `with_var(k, v)` / -//! `with_vars(map)` add arbitrary key→value pairs (`{{date}}`, -//! `{{root_path}}`, `{{toc}}`). Order: `{{content}}` is -//! substituted first so a body that happens to contain a literal -//! `{{title}}` isn't itself rewritten in the next pass. -//! -//! Both delimiter styles are recognised for every placeholder: nuwiki's -//! native `{{key}}` and vimwiki's `%key%` (`%title%`, `%content%`, -//! `%root_path%`, `%date%`, `%wiki_css%`, …). This lets stock vimwiki -//! templates render unchanged. - -use std::collections::HashMap; -use std::io::{self, Write}; -use std::sync::Arc; - -use crate::ast::{ - BlockNode, BlockquoteNode, BoldItalicNode, BoldNode, CheckboxState, CodeNode, ColorNode, - CommentNode, DefinitionItemNode, DefinitionListNode, DocumentNode, ErrorNode, ExternalLinkNode, - HeadingNode, HorizontalRuleNode, InlineNode, ItalicNode, Keyword, KeywordNode, LinkKind, - LinkTarget, ListItemNode, ListNode, MathBlockNode, MathInlineNode, ParagraphNode, - PreformattedNode, RawUrlNode, StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode, - TableNode, TableRowNode, TagNode, TextNode, TransclusionNode, WikiLinkNode, -}; - -use super::Renderer; - -/// Boxed link-resolution callback. Held in an `Arc` so the renderer is -/// cheap to clone and stays `Send + Sync`. -pub type LinkResolver = Arc String + Send + Sync>; - -#[derive(Clone)] -pub struct HtmlRenderer { - link_resolver: LinkResolver, - template: Option, - vars: HashMap, - /// `color_dic`-style override: vimwiki colour-tag names → CSS - /// values (`"red"` / `"#ffe119"` / `"oklch(…)"`). Unspecified - /// names fall through to the default `class="color-"` - /// rendering. Matches vimwiki's `color_dic`. - colors: HashMap, - /// vimwiki `color_tag_template`: the HTML emitted for a colour span whose - /// name resolves in `colors`. `__STYLE__` expands to the inline style - /// (`color:`) and `__CONTENT__` to the rendered inner HTML. The - /// default matches upstream's ``. - color_template: String, - /// vimwiki `html_header_numbering`: the heading level at which automatic - /// section numbering begins (`0` = off, the default). When `>= 1`, every - /// heading at that level or deeper is prefixed with a dotted section - /// number (`1`, `1.1`, `1.2`, …); shallower headings stay unnumbered. - header_numbering: u8, - /// vimwiki `html_header_numbering_sym`: a symbol appended after the - /// section number (e.g. `.` → `1.2. Heading`). Empty by default. - header_numbering_sym: String, - /// vimwiki `toc_header`: the heading text that identifies the TOC - /// section. A heading matching this value (case-insensitive) is wrapped - /// in `
` on export, matching upstream so vimwiki's - /// stylesheet applies. - toc_header: String, - /// vimwiki `valid_html_tags`: inline HTML tag names passed through to - /// export verbatim (instead of escaping `<`/`>`). Empty = escape all. - /// `span` is always allowed so colour spans render. - valid_html_tags: Vec, - /// vimwiki `emoji_enable` (default `false` in the renderer; set from - /// config): substitute `:alias:` shortcodes with their emoji glyph. - emoji_enable: bool, - /// vimwiki `text_ignore_newline` (default `true`): a soft break in a - /// paragraph renders as a space; `false` → `
`. - text_ignore_newline: bool, - /// vimwiki `list_ignore_newline` (default `true`): a soft break in a list - /// item renders as a space; `false` → `
`. - list_ignore_newline: bool, -} - -impl Default for HtmlRenderer { - fn default() -> Self { - Self::new() - } -} - -impl HtmlRenderer { - pub fn new() -> Self { - Self { - link_resolver: Arc::new(default_link_resolver), - template: None, - vars: HashMap::new(), - colors: HashMap::new(), - color_template: "__CONTENT__".to_string(), - header_numbering: 0, - header_numbering_sym: String::new(), - toc_header: String::new(), - valid_html_tags: Vec::new(), - emoji_enable: false, - text_ignore_newline: true, - list_ignore_newline: true, - } - } - - /// Set vimwiki `text_ignore_newline` / `list_ignore_newline`. When `false`, - /// a soft line break in a paragraph / list item renders as `
` - /// instead of a space. - pub fn with_newline_handling(mut self, text_ignore: bool, list_ignore: bool) -> Self { - self.text_ignore_newline = text_ignore; - self.list_ignore_newline = list_ignore; - self - } - - /// Set the inline HTML tags allowed through export unescaped (vimwiki - /// `valid_html_tags`). `span` is always added so colour spans render. - pub fn with_valid_html_tags(mut self, mut tags: Vec) -> Self { - if !tags.iter().any(|t| t.eq_ignore_ascii_case("span")) { - tags.push("span".to_string()); - } - self.valid_html_tags = tags; - self - } - - /// Enable `:alias:` → emoji substitution during export (vimwiki - /// `emoji_enable`). - pub fn with_emoji(mut self, enable: bool) -> Self { - self.emoji_enable = enable; - self - } - - /// Override the link resolver. The callback is invoked for every - /// wikilink and is expected to return a URL string. - pub fn with_link_resolver(mut self, f: F) -> Self - where - F: Fn(&LinkTarget) -> String + Send + Sync + 'static, - { - self.link_resolver = Arc::new(f); - self - } - - /// Wrap the rendered body in a template. `{{title}}` and `{{content}}` - /// are substituted automatically; additional `{{key}}` placeholders - /// resolve via `with_var` / `with_vars`. Anything unresolved passes - /// through verbatim. - pub fn with_template(mut self, template: impl Into) -> Self { - self.template = Some(template.into()); - self - } - - /// Add a single template variable. Re-use to overwrite. - pub fn with_var(mut self, key: impl Into, value: impl Into) -> Self { - self.vars.insert(key.into(), value.into()); - self - } - - /// Replace the entire template variable map. - pub fn with_vars(mut self, vars: HashMap) -> Self { - self.vars = vars; - self - } - - /// Supply a `color_dic`: vimwiki colour-tag names mapped to their - /// CSS values. A name listed here is rendered as `style="color:V"`; - /// missing names fall through to `class="color-"`. - pub fn with_colors(mut self, colors: HashMap) -> Self { - self.colors = colors; - self - } - - /// Override the colour-span template (vimwiki `color_tag_template`). - /// `__STYLE__` is replaced with the resolved `color:` style and - /// `__CONTENT__` with the rendered inner HTML. An empty string keeps the - /// built-in default. - pub fn with_color_template(mut self, template: impl Into) -> Self { - let template = template.into(); - if !template.is_empty() { - self.color_template = template; - } - self - } - - /// Enable vimwiki-style HTML section numbering. `level` is the heading - /// level at which numbering starts (`0` disables it); `sym` is appended - /// after the number. Mirrors `html_header_numbering` / - /// `html_header_numbering_sym`. - pub fn with_header_numbering(mut self, level: u8, sym: impl Into) -> Self { - self.header_numbering = level; - self.header_numbering_sym = sym.into(); - self - } - - /// Set the vimwiki TOC heading text. When a heading matches this value - /// (case-insensitive) it gets `class="toc"` in HTML export. - pub fn with_toc_header(mut self, header: impl Into) -> Self { - self.toc_header = header.into(); - self - } -} - -/// Running state for vimwiki-style HTML section numbering -/// (`html_header_numbering`). Headings are visited in document order; each -/// at or below the start level bumps its depth's counter, resets deeper -/// counters, and yields a dotted prefix (`1`, `1.1`, `2`, …). -struct HeadingNumberer { - /// Heading level numbering starts at (`0` disables it). - start: u8, - /// Symbol appended after the dotted number. - sym: String, - /// Per-depth counters, indexed by `level - start` (levels clamp to 1–6, - /// so the depth is always `0..=5`). - counters: [u32; 6], -} - -impl HeadingNumberer { - fn new(start: u8, sym: &str) -> Self { - Self { - start, - sym: sym.to_string(), - counters: [0; 6], - } - } - - /// Advance to a heading at `level` (1–6) and return its numeric prefix, - /// including the trailing symbol and a separating space (e.g. `"1.2. "`). - /// Returns an empty string when numbering is off or the heading is - /// shallower than the start level. - fn prefix(&mut self, level: u8) -> String { - if self.start == 0 || level < self.start { - return String::new(); - } - let depth = (level - self.start) as usize; - self.counters[depth] += 1; - for c in self.counters.iter_mut().skip(depth + 1) { - *c = 0; - } - let num = self.counters[..=depth] - .iter() - .map(|c| c.to_string()) - .collect::>() - .join("."); - format!("{num}{} ", self.sym) - } -} - -impl Renderer for HtmlRenderer { - fn render(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> { - if let Some(template) = &self.template { - let mut body = Vec::new(); - self.render_body(doc, &mut body)?; - let body_str = std::str::from_utf8(&body) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - let title = doc.metadata.title.as_deref().unwrap_or(""); - // Substitute `content` before `title` so a body that legitimately - // contains a literal title placeholder isn't rewritten. Both the - // native `{{key}}` and vimwiki's `%key%` delimiters are accepted so - // stock vimwiki templates render unchanged. - let mut out = template.replace("{{content}}", body_str); - out = out.replace("%content%", body_str); - out = out.replace("{{title}}", title); - out = out.replace("%title%", title); - for (k, v) in &self.vars { - out = out.replace(&format!("{{{{{k}}}}}"), v); - out = out.replace(&format!("%{k}%"), v); - } - w.write_all(out.as_bytes())?; - } else { - self.render_body(doc, w)?; - } - Ok(()) - } -} - -impl HtmlRenderer { - fn render_body(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> { - // Section numbering runs over top-level headings in document order, - // mirroring vimwiki's line-by-line scan. Nested headings (in lists, - // quotes) go through `render_block` and are left unnumbered, matching - // upstream which only numbers document-level headers. - let mut numberer = HeadingNumberer::new(self.header_numbering, &self.header_numbering_sym); - // Stack of (level, anchor) for the headings enclosing the current one, - // so each heading can carry vimwiki's hierarchical id (the parent - // anchors joined by `-`, e.g. `Title-Section`). - let mut ancestors: Vec<(u8, String)> = Vec::new(); - for block in &doc.children { - if let BlockNode::Heading(n) = block { - let level = n.level.clamp(1, 6); - let number = numberer.prefix(level); - let anchor = crate::ast::inline_text(&n.children); - while ancestors.last().is_some_and(|(l, _)| *l >= level) { - ancestors.pop(); - } - let mut hier = String::new(); - for (_, a) in &ancestors { - hier.push_str(a); - hier.push('-'); - } - hier.push_str(&anchor); - ancestors.push((level, anchor)); - self.render_heading(n, &number, &hier, w)?; - } else { - self.render_block(block, w)?; - } - } - Ok(()) - } - - fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> { - match block { - BlockNode::Heading(n) => self.render_heading(n, "", "", w), - BlockNode::Paragraph(n) => self.render_paragraph(n, w), - BlockNode::HorizontalRule(n) => self.render_hr(n, w), - BlockNode::Blockquote(n) => self.render_blockquote(n, w), - BlockNode::Preformatted(n) => self.render_preformatted(n, w), - BlockNode::MathBlock(n) => self.render_math_block(n, w), - BlockNode::List(n) => self.render_list(n, w), - BlockNode::DefinitionList(n) => self.render_definition_list(n, w), - BlockNode::Table(n) => self.render_table(n, w), - BlockNode::Comment(n) => self.render_comment(n, w), - BlockNode::Tag(n) => self.render_tag(n, w), - BlockNode::Error(n) => self.render_error(n, w), - } - } - - fn render_tag(&self, n: &TagNode, w: &mut dyn Write) -> io::Result<()> { - // `id="…"` (bare tag name, matching vimwiki) lets `[[Page#name]]` jump - // to the rendered tag. - w.write_all(b"
")?; - for (i, name) in n.tags.iter().enumerate() { - if i > 0 { - w.write_all(b" ")?; - } - w.write_all(b"")?; - write_escaped(name, w)?; - w.write_all(b"")?; - } - w.write_all(b"
\n") - } - - /// Render a heading. `number` is the optional section-number prefix - /// (already including its trailing symbol and space, e.g. `"1.2. "`); - /// pass `""` for no numbering. `hier_id` is the hierarchical anchor id - /// (parent anchors joined by `-`); pass `""` to default it to the flat id. - fn render_heading( - &self, - n: &HeadingNode, - number: &str, - hier_id: &str, - w: &mut dyn Write, - ) -> io::Result<()> { - let level = n.level.clamp(1, 6); - let anchor = crate::ast::inline_text(&n.children); - // Inside a heading, render TODO/DONE/… as plain text rather than a - // keyword badge — a section title that *is* a keyword (`== TODO ==`) - // should look like a header, not an inline tag. (The keyword text is - // still part of `anchor` above, so the id stays correct.) - let body = flatten_keywords(&n.children); - - // The TOC section heading (vimwiki `toc_header`) is wrapped in a - // `
`, matching upstream so vimwiki's `.toc { … }` - // stylesheet rules apply. Kept in its simple form. - let is_toc = !self.toc_header.is_empty() && anchor.eq_ignore_ascii_case(&self.toc_header); - if is_toc { - w.write_all(b"
")?; - write!(w, "")?; - if !number.is_empty() { - write_escaped(number, w)?; - } - self.render_inlines(&body, w)?; - write!(w, "
")?; - return writeln!(w); - } - - // Centered headings keep their `class="centered"` form. - if n.centered { - write!(w, "")?; - if !number.is_empty() { - write_escaped(number, w)?; - } - self.render_inlines(&body, w)?; - write!(w, "")?; - return writeln!(w); - } - - // Regular heading: vimwiki's structure — a wrapping `
` carrying the - // hierarchical id, `class="header"` on the heading, and an in-heading - // self-anchor linking to that id. The heading keeps the flat id so - // existing intra-page TOC / `#anchor` links still resolve. - let hier = if hier_id.is_empty() { &anchor } else { hier_id }; - w.write_all(b"")?; - writeln!(w) - } - - fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> { - w.write_all(b"

")?; - let sb = if self.text_ignore_newline { - " " - } else { - "
" - }; - self.render_inlines_break(&n.children, sb, w)?; - w.write_all(b"

\n") - } - - fn render_hr(&self, _n: &HorizontalRuleNode, w: &mut dyn Write) -> io::Result<()> { - w.write_all(b"
\n") - } - - fn render_blockquote(&self, n: &BlockquoteNode, w: &mut dyn Write) -> io::Result<()> { - w.write_all(b"
\n")?; - for child in &n.children { - self.render_block(child, w)?; - } - w.write_all(b"
\n") - } - - fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> { - // vimwiki drops the verbatim fence text into the `
` tag as raw
-        // attributes (`{{{python` → `
`), with no inner ``.
-        // We mirror that so highlighters wired for vimwiki output still apply.
-        if n.attrs.is_empty() {
-            w.write_all(b"
")?;
-        } else {
-            write!(w, "
", n.attrs)?;
-        }
-        write_escaped(&n.content, w)?;
-        w.write_all(b"
\n") - } - - fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> { - // No actual TeX rendering — caller is expected to run MathJax / KaTeX - // over the wrapped node. The delimiters match vimwiki convention. - match &n.environment { - Some(env) => writeln!( - w, - "
\\begin{{{env}}}", - escape(env) - )?, - None => w.write_all(b"
\\[\n")?, - } - write_escaped(&n.content, w)?; - match &n.environment { - Some(env) => write!(w, "\n\\end{{{env}}}
\n", env = escape(env))?, - None => w.write_all(b"\n\\]
\n")?, - } - Ok(()) - } - - fn render_list(&self, n: &ListNode, w: &mut dyn Write) -> io::Result<()> { - let tag = if n.ordered { "ol" } else { "ul" }; - writeln!(w, "<{tag}>")?; - for item in &n.items { - self.render_list_item(item, w)?; - } - writeln!(w, "") - } - - fn render_list_item(&self, n: &ListItemNode, w: &mut dyn Write) -> io::Result<()> { - match n.checkbox { - Some(state) => { - // vimwiki-compatible checkbox classes: `[ ]`→done0, `[.]`→done1, - // `[o]`→done2, `[O]`→done3, `[X]`→done4, `[-]`→rejected. The - // checkbox glyph/progress fill is drawn entirely by the - // stylesheet (`li.doneN::before`); vimwiki emits no ``, - // so neither do we — an input would double-render against it. - let class = match state { - CheckboxState::Empty => "done0", - CheckboxState::Quarter => "done1", - CheckboxState::Half => "done2", - CheckboxState::ThreeQuarters => "done3", - CheckboxState::Done => "done4", - CheckboxState::Rejected => "rejected", - }; - write!(w, "
  • ")?; - } - None => w.write_all(b"
  • ")?, - } - let sb = if self.list_ignore_newline { - " " - } else { - "
    " - }; - self.render_inlines_break(&n.children, sb, w)?; - if let Some(sublist) = &n.sublist { - w.write_all(b"\n")?; - self.render_list(sublist, w)?; - } - w.write_all(b"
  • \n") - } - - fn render_definition_list(&self, n: &DefinitionListNode, w: &mut dyn Write) -> io::Result<()> { - w.write_all(b"
    \n")?; - for item in &n.items { - self.render_definition_item(item, w)?; - } - w.write_all(b"
    \n") - } - - fn render_definition_item(&self, n: &DefinitionItemNode, w: &mut dyn Write) -> io::Result<()> { - if let Some(term) = &n.term { - w.write_all(b"
    ")?; - self.render_inlines(term, w)?; - w.write_all(b"
    \n")?; - } - for def in &n.definitions { - w.write_all(b"
    ")?; - self.render_inlines(def, w)?; - w.write_all(b"
    \n")?; - } - Ok(()) - } - - fn render_table(&self, n: &TableNode, w: &mut dyn Write) -> io::Result<()> { - // Pre-compute per-cell rowspan/colspan + suppression so we can - // emit valid HTML colspan/rowspan attributes instead of CSS - // class markers. `col_span` cells get folded into the colspan - // of the lead cell to their left; `row_span` cells get folded - // into the rowspan of the lead cell directly above. - let layout = table_spans(n); - w.write_all(b"\n")?; - let mut in_thead = false; - let mut in_tbody = false; - for (row_idx, row) in n.rows.iter().enumerate() { - if row.is_header { - if !in_thead { - w.write_all(b"\n")?; - in_thead = true; - } - } else { - if in_thead { - w.write_all(b"\n")?; - in_thead = false; - } - if !in_tbody { - w.write_all(b"\n")?; - in_tbody = true; - } - } - self.render_table_row(row, row_idx, &layout, &n.alignments, w)?; - } - if in_thead { - w.write_all(b"\n")?; - } - if in_tbody { - w.write_all(b"\n")?; - } - w.write_all(b"
    \n") - } - - fn render_table_row( - &self, - n: &TableRowNode, - row_idx: usize, - layout: &[Vec], - alignments: &[crate::ast::TableAlign], - w: &mut dyn Write, - ) -> io::Result<()> { - w.write_all(b"")?; - for (col_idx, cell) in n.cells.iter().enumerate() { - let info = layout - .get(row_idx) - .and_then(|r| r.get(col_idx)) - .copied() - .unwrap_or(CellLayout::Lead { - colspan: 1, - rowspan: 1, - }); - match info { - CellLayout::Skip => continue, - CellLayout::Lead { colspan, rowspan } => { - let align = alignments - .get(col_idx) - .copied() - .unwrap_or(crate::ast::TableAlign::Default); - self.render_table_cell(cell, n.is_header, colspan, rowspan, align, w)?; - } - } - } - w.write_all(b"\n") - } - - fn render_table_cell( - &self, - n: &TableCellNode, - is_header: bool, - colspan: u32, - rowspan: u32, - align: crate::ast::TableAlign, - w: &mut dyn Write, - ) -> io::Result<()> { - let tag = if is_header { "th" } else { "td" }; - write!(w, "<{tag}")?; - if colspan > 1 { - write!(w, " colspan=\"{colspan}\"")?; - } - if rowspan > 1 { - write!(w, " rowspan=\"{rowspan}\"")?; - } - let align_attr = match align { - crate::ast::TableAlign::Left => Some("left"), - crate::ast::TableAlign::Right => Some("right"), - crate::ast::TableAlign::Center => Some("center"), - crate::ast::TableAlign::Default => None, - }; - if let Some(a) = align_attr { - write!(w, " style=\"text-align: {a};\"")?; - } - write!(w, ">")?; - self.render_inlines(&n.children, w)?; - write!(w, "") - } - - fn render_comment(&self, n: &CommentNode, w: &mut dyn Write) -> io::Result<()> { - // Emit as an HTML comment so it survives round-trips through view-source - // but isn't rendered to the reader. `--` inside a comment is illegal - // in HTML, so collapse runs. - let safe = n.content.replace("--", "- -"); - writeln!(w, "") - } - - fn render_error(&self, n: &ErrorNode, w: &mut dyn Write) -> io::Result<()> { - write!(w, "")?; - write_escaped(&n.raw, w)?; - w.write_all(b"") - } - - // ===== Inline ===== - - fn render_inlines(&self, nodes: &[InlineNode], w: &mut dyn Write) -> io::Result<()> { - for n in nodes { - self.render_inline(n, w)?; - } - Ok(()) - } - - /// Like [`render_inlines`] but renders top-level soft breaks as `soft_break` - /// (a space or `
    `). Used by paragraphs / list items, which are the - /// only blocks that contain `SoftBreak` nodes. - fn render_inlines_break( - &self, - nodes: &[InlineNode], - soft_break: &str, - w: &mut dyn Write, - ) -> io::Result<()> { - for n in nodes { - match n { - InlineNode::SoftBreak(_) => w.write_all(soft_break.as_bytes())?, - _ => self.render_inline(n, w)?, - } - } - Ok(()) - } - - fn render_inline(&self, n: &InlineNode, w: &mut dyn Write) -> io::Result<()> { - match n { - InlineNode::Text(t) => self.render_text(t, w), - InlineNode::Bold(t) => self.render_bold(t, w), - InlineNode::Italic(t) => self.render_italic(t, w), - InlineNode::BoldItalic(t) => self.render_bold_italic(t, w), - InlineNode::Strikethrough(t) => self.render_strikethrough(t, w), - InlineNode::Code(t) => self.render_code(t, w), - InlineNode::Superscript(t) => self.render_superscript(t, w), - InlineNode::Subscript(t) => self.render_subscript(t, w), - InlineNode::MathInline(t) => self.render_math_inline(t, w), - InlineNode::Keyword(t) => self.render_keyword(t, w), - InlineNode::Color(t) => self.render_color(t, w), - InlineNode::WikiLink(t) => self.render_wikilink(t, w), - InlineNode::ExternalLink(t) => self.render_external_link(t, w), - InlineNode::Transclusion(t) => self.render_transclusion(t, w), - InlineNode::RawUrl(t) => self.render_raw_url(t, w), - // Default rendering (e.g. inside a heading): a space. Paragraphs - // and list items use render_inlines_break for `
    ` support. - InlineNode::SoftBreak(_) => w.write_all(b" "), - } - } - - fn render_text(&self, n: &TextNode, w: &mut dyn Write) -> io::Result<()> { - let content = if self.emoji_enable { - substitute_emoji(&n.content) - } else { - std::borrow::Cow::Borrowed(n.content.as_str()) - }; - if self.valid_html_tags.is_empty() { - write_escaped(&content, w) - } else { - write_escaped_allowing(&content, &self.valid_html_tags, w) - } - } - - fn render_bold(&self, n: &BoldNode, w: &mut dyn Write) -> io::Result<()> { - w.write_all(b"")?; - self.render_inlines(&n.children, w)?; - w.write_all(b"") - } - - fn render_italic(&self, n: &ItalicNode, w: &mut dyn Write) -> io::Result<()> { - w.write_all(b"")?; - self.render_inlines(&n.children, w)?; - w.write_all(b"") - } - - fn render_bold_italic(&self, n: &BoldItalicNode, w: &mut dyn Write) -> io::Result<()> { - w.write_all(b"")?; - self.render_inlines(&n.children, w)?; - w.write_all(b"") - } - - fn render_strikethrough(&self, n: &StrikethroughNode, w: &mut dyn Write) -> io::Result<()> { - w.write_all(b"")?; - self.render_inlines(&n.children, w)?; - w.write_all(b"") - } - - fn render_code(&self, n: &CodeNode, w: &mut dyn Write) -> io::Result<()> { - w.write_all(b"")?; - write_escaped(&n.content, w)?; - w.write_all(b"") - } - - fn render_superscript(&self, n: &SuperscriptNode, w: &mut dyn Write) -> io::Result<()> { - w.write_all(b"")?; - self.render_inlines(&n.children, w)?; - w.write_all(b"") - } - - fn render_subscript(&self, n: &SubscriptNode, w: &mut dyn Write) -> io::Result<()> { - w.write_all(b"")?; - self.render_inlines(&n.children, w)?; - w.write_all(b"") - } - - fn render_math_inline(&self, n: &MathInlineNode, w: &mut dyn Write) -> io::Result<()> { - w.write_all(b"\\(")?; - write_escaped(&n.content, w)?; - w.write_all(b"\\)") - } - - fn render_keyword(&self, n: &KeywordNode, w: &mut dyn Write) -> io::Result<()> { - // One class per keyword so stylesheets can colour them independently - // (e.g. red TODO/FIXME/XXX vs green DONE/FIXED/STARTED). TODO keeps the - // vimwiki `todo` class so stock vimwiki CSS still styles it. - let class = match n.keyword { - Keyword::Todo => "todo", - Keyword::Done => "done", - Keyword::Started => "started", - Keyword::Fixme => "fixme", - Keyword::Fixed => "fixed", - Keyword::Xxx => "xxx", - Keyword::Stopped => "stopped", - }; - write!(w, "{}", n.keyword.label()) - } - - fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> { - // Render the inner content up front so it can be slotted into the - // colour template. - let mut content = Vec::new(); - self.render_inlines(&n.children, &mut content)?; - let content = String::from_utf8_lossy(&content); - - if let Some(css) = self.colors.get(&n.color) { - // Known colour name: expand `color_tag_template` with the resolved - // inline style and rendered content. - let mut style = b"color:".to_vec(); - write_escaped(css, &mut style)?; - let style = String::from_utf8_lossy(&style); - let html = self - .color_template - .replace("__STYLE__", &style) - .replace("__CONTENT__", &content); - w.write_all(html.as_bytes()) - } else { - // Unknown name: fall back to a class hook the stylesheet can target - // (the template only models the inline-style case). - w.write_all(b"")?; - w.write_all(content.as_bytes())?; - w.write_all(b"") - } - } - - fn render_wikilink(&self, n: &WikiLinkNode, w: &mut dyn Write) -> io::Result<()> { - let href = (self.link_resolver)(&n.target); - w.write_all(b"")?; - match &n.description { - Some(desc) => self.render_inlines(desc, w)?, - None => { - let label = n - .target - .path - .clone() - .or_else(|| n.target.anchor.clone()) - .unwrap_or_else(|| href.clone()); - write_escaped(&label, w)?; - } - } - w.write_all(b"") - } - - fn render_external_link(&self, n: &ExternalLinkNode, w: &mut dyn Write) -> io::Result<()> { - w.write_all(b"")?; - match &n.description { - Some(desc) => self.render_inlines(desc, w)?, - None => write_escaped(&n.url, w)?, - } - w.write_all(b"") - } - - fn render_transclusion(&self, n: &TransclusionNode, w: &mut dyn Write) -> io::Result<()> { - w.write_all(b"\"")?; = n.attrs.keys().collect(); - keys.sort(); - for k in keys { - let v = &n.attrs[k]; - w.write_all(b" ")?; - write_escaped(k, w)?; - w.write_all(b"=\"")?; - write_escaped(v, w)?; - w.write_all(b"\"")?; - } - w.write_all(b">") - } - - fn render_raw_url(&self, n: &RawUrlNode, w: &mut dyn Write) -> io::Result<()> { - w.write_all(b"")?; - write_escaped(&n.url, w)?; - w.write_all(b"") - } -} - -// ===== Table colspan/rowspan layout ===== - -/// Per-cell render decision: emit a lead cell with the computed spans, -/// or skip (this cell is a continuation marker absorbed by its lead). -#[derive(Debug, Clone, Copy)] -enum CellLayout { - Lead { colspan: u32, rowspan: u32 }, - Skip, -} - -/// Walk every cell in the table and resolve `col_span` / `row_span` -/// markers into HTML `colspan="N"` / `rowspan="N"` attributes on the -/// preceding lead cell. -/// -/// Rules: -/// - `col_span: true` (`>` in vimwiki source) bumps the colspan of -/// the lead cell to its *left* in the same row. -/// - `row_span: true` (`\\/` in vimwiki source) bumps the rowspan -/// of the lead cell *above* in the same column. -/// - Markers that don't have a valid lead cell (first column / -/// first row) render as a plain empty cell so the document still -/// parses cleanly. -fn table_spans(table: &TableNode) -> Vec> { - let rows = table.rows.len(); - if rows == 0 { - return Vec::new(); - } - let cols = table.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0); - let mut layout: Vec> = (0..rows) - .map(|_| { - (0..cols) - .map(|_| CellLayout::Lead { - colspan: 1, - rowspan: 1, - }) - .collect() - }) - .collect(); - for (ri, row) in table.rows.iter().enumerate() { - for (ci, cell) in row.cells.iter().enumerate() { - if cell.col_span { - // Find the lead cell to the left in this row (skipping - // already-skipped continuation slots) and bump it. - let mut k = ci; - while k > 0 { - k -= 1; - match layout[ri][k] { - CellLayout::Lead { - ref mut colspan, .. - } => { - *colspan += 1; - layout[ri][ci] = CellLayout::Skip; - break; - } - CellLayout::Skip => continue, - } - } - } else if cell.row_span { - // Walk up in the same column for the lead cell. - let mut k = ri; - while k > 0 { - k -= 1; - match layout[k][ci] { - CellLayout::Lead { - ref mut rowspan, .. - } => { - *rowspan += 1; - layout[ri][ci] = CellLayout::Skip; - break; - } - CellLayout::Skip => continue, - } - } - } - } - } - layout -} - -// ===== Default link resolver ===== - -/// Default `LinkResolver`. Mirrors the vimwiki link conventions: -/// wiki pages become `path.html`; interwiki links land in sibling -/// directories; diary entries land under `diary/`; file/local schemes -/// use their literal path; anchor-only links collapse to a fragment. -/// Return a copy of `nodes` with every `Keyword` replaced by its literal text. -/// Used for heading content so a keyword in a title renders as plain text (with -/// the heading's own styling) rather than as an inline keyword badge. -fn flatten_keywords(nodes: &[InlineNode]) -> Vec { - nodes - .iter() - .map(|n| match n { - InlineNode::Keyword(k) => InlineNode::Text(TextNode { - span: k.span, - content: k.keyword.label().to_string(), - }), - InlineNode::Bold(b) => InlineNode::Bold(BoldNode { - span: b.span, - children: flatten_keywords(&b.children), - }), - InlineNode::Italic(i) => InlineNode::Italic(ItalicNode { - span: i.span, - children: flatten_keywords(&i.children), - }), - InlineNode::BoldItalic(b) => InlineNode::BoldItalic(BoldItalicNode { - span: b.span, - children: flatten_keywords(&b.children), - }), - InlineNode::Strikethrough(s) => InlineNode::Strikethrough(StrikethroughNode { - span: s.span, - children: flatten_keywords(&s.children), - }), - InlineNode::Superscript(s) => InlineNode::Superscript(SuperscriptNode { - span: s.span, - children: flatten_keywords(&s.children), - }), - InlineNode::Subscript(s) => InlineNode::Subscript(SubscriptNode { - span: s.span, - children: flatten_keywords(&s.children), - }), - InlineNode::Color(c) => InlineNode::Color(ColorNode { - span: c.span, - color: c.color.clone(), - children: flatten_keywords(&c.children), - }), - other => other.clone(), - }) - .collect() -} - -pub fn default_link_resolver(target: &LinkTarget) -> String { - if matches!(target.kind, LinkKind::AnchorOnly) { - return match &target.anchor { - Some(a) => format!("#{a}"), - None => String::new(), - }; - } - - let path = target.path.as_deref().unwrap_or(""); - let mut out = String::new(); - - match target.kind { - LinkKind::Wiki => { - if target.is_absolute { - out.push('/'); - } - out.push_str(path); - if target.is_directory { - out.push('/'); - } else if !path.is_empty() { - out.push_str(".html"); - } - } - LinkKind::Interwiki => { - if let Some(idx) = target.wiki_index { - out.push_str(&format!("../wiki{idx}/")); - } else if let Some(name) = &target.wiki_name { - out.push_str(&format!("../wn-{name}/")); - } - out.push_str(path); - if !target.is_directory { - out.push_str(".html"); - } - } - LinkKind::Diary => { - out.push_str("diary/"); - out.push_str(path); - out.push_str(".html"); - } - LinkKind::File => { - out.push_str(path); - } - LinkKind::Local => { - out.push_str("file://"); - if target.is_absolute && !path.starts_with('/') { - out.push('/'); - } - out.push_str(path); - } - LinkKind::Raw => { - out.push_str(path); - } - LinkKind::AnchorOnly => unreachable!(), - } - - if let Some(anchor) = &target.anchor { - out.push('#'); - out.push_str(anchor); - } - out -} - -// ===== HTML escaping ===== - -/// Escape `s` for HTML, but pass through inline tags whose name is in -/// `allowed` (vimwiki `valid_html_tags`) verbatim — mirroring upstream's -/// render-time `s:safe_html_line` regex rather than parsing raw HTML. -fn write_escaped_allowing(s: &str, allowed: &[String], w: &mut dyn Write) -> io::Result<()> { - let bytes = s.as_bytes(); - let mut i = 0; - let mut last = 0; - while i < bytes.len() { - if bytes[i] == b'<' { - if let Some(end) = match_allowed_tag(bytes, i, allowed) { - write_escaped(&s[last..i], w)?; - w.write_all(&bytes[i..end])?; - i = end; - last = end; - continue; - } - } - i += 1; - } - write_escaped(&s[last..], w) -} - -/// If `bytes[lt]` (`<`) begins a `` / `` whose name is in -/// `allowed`, return the index just past the closing `>`. Otherwise `None`. -fn match_allowed_tag(bytes: &[u8], lt: usize, allowed: &[String]) -> Option { - let mut p = lt + 1; - if bytes.get(p) == Some(&b'/') { - p += 1; - } - let name_begin = p; - while p < bytes.len() && (bytes[p] as char).is_ascii_alphabetic() { - p += 1; - } - if p == name_begin { - return None; - } - let name = std::str::from_utf8(&bytes[name_begin..p]).ok()?; - if !allowed.iter().any(|t| t.eq_ignore_ascii_case(name)) { - return None; - } - // Scan to the closing `>` (bail on a nested `<` — not a well-formed tag). - while p < bytes.len() && bytes[p] != b'>' { - if bytes[p] == b'<' { - return None; - } - p += 1; - } - if p >= bytes.len() { - return None; - } - Some(p + 1) -} - -/// Replace `:alias:` emoji shortcodes with their glyph (vimwiki -/// `emoji_enable`). Only a curated common subset is supported; unknown -/// shortcodes are left untouched. -fn substitute_emoji(s: &str) -> std::borrow::Cow<'_, str> { - if !s.contains(':') { - return std::borrow::Cow::Borrowed(s); - } - let mut out = String::with_capacity(s.len()); - let mut rest = s; - let mut changed = false; - while let Some(open) = rest.find(':') { - out.push_str(&rest[..open]); - let after = &rest[open + 1..]; - if let Some(close) = after.find(':') { - let alias = &after[..close]; - if !alias.is_empty() - && alias - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '+' || c == '-') - { - if let Some(glyph) = emoji_for(alias) { - out.push_str(glyph); - rest = &after[close + 1..]; - changed = true; - continue; - } - } - } - // Not a shortcode — keep the `:` and continue past it. - out.push(':'); - rest = after; - } - out.push_str(rest); - if changed { - std::borrow::Cow::Owned(out) - } else { - std::borrow::Cow::Borrowed(s) - } -} - -/// A curated subset of GitHub-style emoji shortcodes. -fn emoji_for(alias: &str) -> Option<&'static str> { - Some(match alias { - "smile" => "😄", - "smiley" => "😃", - "grin" => "😁", - "laughing" | "satisfied" => "😆", - "wink" => "😉", - "blush" => "😊", - "heart" => "❤️", - "broken_heart" => "💔", - "thumbsup" | "+1" => "👍", - "thumbsdown" | "-1" => "👎", - "ok_hand" => "👌", - "wave" => "👋", - "clap" => "👏", - "fire" => "🔥", - "star" => "⭐", - "sparkles" => "✨", - "zap" => "⚡", - "boom" => "💥", - "tada" => "🎉", - "rocket" => "🚀", - "bulb" => "💡", - "bug" => "🐛", - "warning" => "⚠️", - "white_check_mark" | "check" => "✅", - "x" => "❌", - "question" => "❓", - "exclamation" => "❗", - "eyes" => "👀", - "100" => "💯", - "pray" => "🙏", - "muscle" => "💪", - "coffee" => "☕", - "book" => "📖", - "memo" | "pencil" => "📝", - "lock" => "🔒", - "key" => "🔑", - "bell" => "🔔", - "hourglass" => "⌛", - "calendar" => "📅", - _ => return None, - }) -} - -fn write_escaped(s: &str, w: &mut dyn Write) -> io::Result<()> { - let bytes = s.as_bytes(); - let mut last = 0; - for (i, &b) in bytes.iter().enumerate() { - let replacement: &[u8] = match b { - b'<' => b"<", - b'>' => b">", - b'&' => b"&", - b'"' => b""", - b'\'' => b"'", - _ => continue, - }; - if i > last { - w.write_all(&bytes[last..i])?; - } - w.write_all(replacement)?; - last = i + 1; - } - if last < bytes.len() { - w.write_all(&bytes[last..])?; - } - Ok(()) -} - -fn escape(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - match c { - '<' => out.push_str("<"), - '>' => out.push_str(">"), - '&' => out.push_str("&"), - '"' => out.push_str("""), - '\'' => out.push_str("'"), - c => out.push(c), - } - } - out -} diff --git a/crates/nuwiki-core/src/render/mod.rs b/crates/nuwiki-core/src/render/mod.rs deleted file mode 100644 index d22520a..0000000 --- a/crates/nuwiki-core/src/render/mod.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! Document renderers. -//! -//! A `Renderer` is writer-based, takes a `DocumentNode`, and -//! emits its representation into any `Write`. Errors propagate through -//! `io::Result` — there's no separate per-renderer error type so the trait -//! stays object-safe (`Box` is useful for the LSP later). - -pub mod html; - -pub use html::HtmlRenderer; - -use std::io::{self, Write}; - -use crate::ast::DocumentNode; - -pub trait Renderer { - fn render(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()>; - - /// Convenience: render into a `String`. Renderers that emit non-UTF-8 - /// bytes should override or skip; the default assumes UTF-8 output. - fn render_to_string(&self, doc: &DocumentNode) -> io::Result { - let mut buf = Vec::new(); - self.render(doc, &mut buf)?; - String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) - } -} diff --git a/crates/nuwiki-core/src/syntax/mod.rs b/crates/nuwiki-core/src/syntax/mod.rs deleted file mode 100644 index 39fc746..0000000 --- a/crates/nuwiki-core/src/syntax/mod.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! Syntax plugin interface. -//! -//! Each syntax (vimwiki today, markdown later) implements the same shape: -//! a `Lexer` produces a `TokenStream`, a `Parser` consumes it and produces a -//! `DocumentNode`. A `SyntaxPlugin` is the type-erased facade that the LSP -//! layer holds in a `SyntaxRegistry`. - -pub mod registry; -pub mod vimwiki; - -pub use registry::SyntaxRegistry; - -use crate::ast::DocumentNode; - -/// Eager token buffer produced by a `Lexer`. The newtype keeps the door -/// open to a lazy/streaming variant later without breaking -/// callers. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct TokenStream { - tokens: Vec, -} - -impl TokenStream { - pub fn new() -> Self { - Self { tokens: Vec::new() } - } - - pub fn from_vec(tokens: Vec) -> Self { - Self { tokens } - } - - pub fn into_vec(self) -> Vec { - self.tokens - } - - pub fn as_slice(&self) -> &[T] { - &self.tokens - } - - pub fn len(&self) -> usize { - self.tokens.len() - } - - pub fn is_empty(&self) -> bool { - self.tokens.is_empty() - } - - pub fn iter(&self) -> std::slice::Iter<'_, T> { - self.tokens.iter() - } - - pub fn push(&mut self, token: T) { - self.tokens.push(token); - } -} - -impl From> for TokenStream { - fn from(tokens: Vec) -> Self { - Self::from_vec(tokens) - } -} - -impl IntoIterator for TokenStream { - type Item = T; - type IntoIter = std::vec::IntoIter; - - fn into_iter(self) -> Self::IntoIter { - self.tokens.into_iter() - } -} - -impl<'a, T> IntoIterator for &'a TokenStream { - type Item = &'a T; - type IntoIter = std::slice::Iter<'a, T>; - - fn into_iter(self) -> Self::IntoIter { - self.tokens.iter() - } -} - -/// Lexer half of a syntax plugin. Operates on raw source text and emits a -/// `TokenStream` whose token type is syntax-specific (e.g. `VimwikiToken`). -pub trait Lexer { - type Token; - - fn lex(&self, text: &str) -> TokenStream; -} - -/// Parser half of a syntax plugin. Consumes a `TokenStream` and produces a -/// `DocumentNode`. Parsing is resilient — malformed input -/// becomes `BlockNode::Error(ErrorNode)`, never a hard failure. -pub trait Parser { - type Token; - - fn parse(&self, tokens: TokenStream) -> DocumentNode; -} - -/// Type-erased syntax plugin held by the `SyntaxRegistry`. -/// -/// Implementations typically own a `Lexer` and a `Parser` and chain them -/// inside `parse`. The token type is deliberately hidden so the registry can -/// hold heterogeneous plugins behind a single trait object. -/// -/// `Send + Sync` is required — the LSP server stores plugins behind an `Arc` -/// and shares them across request handler tasks. -pub trait SyntaxPlugin: Send + Sync { - fn id(&self) -> &str; - fn display_name(&self) -> &str; - - /// File extensions associated with this syntax. Each entry includes the - /// leading dot, e.g. `[".wiki"]`. - fn file_extensions(&self) -> &[&str]; - - fn parse(&self, text: &str) -> DocumentNode; -} diff --git a/crates/nuwiki-core/src/syntax/registry.rs b/crates/nuwiki-core/src/syntax/registry.rs deleted file mode 100644 index a8eeb92..0000000 --- a/crates/nuwiki-core/src/syntax/registry.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Registry of syntax plugins. -//! -//! Plugins are stored behind `Arc` so the LSP layer can -//! hand a cheap clone to per-document tasks without re-locking the registry. - -use std::sync::Arc; - -use super::SyntaxPlugin; - -#[derive(Default, Clone)] -pub struct SyntaxRegistry { - plugins: Vec>, -} - -impl SyntaxRegistry { - pub fn new() -> Self { - Self::default() - } - - /// Register a plugin owned directly. The most common shape — implementer - /// hands over a value, registry takes ownership. - pub fn register

    (&mut self, plugin: P) - where - P: SyntaxPlugin + 'static, - { - self.plugins.push(Arc::new(plugin)); - } - - /// Look up a plugin by its `id`. - pub fn get(&self, id: &str) -> Option<&dyn SyntaxPlugin> { - self.plugins - .iter() - .find(|p| p.id() == id) - .map(|p| p.as_ref()) - } - - /// Look up a plugin by file extension. `ext` should include the leading - /// dot (e.g. `".wiki"`) — matches the plugin's `file_extensions()`. - pub fn detect_from_extension(&self, ext: &str) -> Option<&dyn SyntaxPlugin> { - self.plugins - .iter() - .find(|p| p.file_extensions().iter().any(|e| *e == ext)) - .map(|p| p.as_ref()) - } - - /// Iterate over registered plugins in registration order. - pub fn iter(&self) -> impl Iterator + '_ { - self.plugins.iter().map(|p| p.as_ref()) - } - - pub fn len(&self) -> usize { - self.plugins.len() - } - - pub fn is_empty(&self) -> bool { - self.plugins.is_empty() - } -} diff --git a/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs b/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs deleted file mode 100644 index 2d4b1af..0000000 --- a/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs +++ /dev/null @@ -1,1291 +0,0 @@ -//! Vimwiki lexer. -//! -//! Hand-rolled, two-pass: -//! -//! - **Block pass:** scan the source line by line, recognise structural -//! constructs (headings, lists, tables, fences, comments, etc.), emit -//! the matching block-level token, and within text-bearing lines invoke -//! the inline pass on the line content. -//! - **Inline pass:** scan a text run byte by byte, accumulate plain text, -//! and emit inline marker tokens (bold, italic, code, links, …). -//! -//! Both passes share one `Vec` so the parser sees a single, -//! ordered stream. The lexer is permissive: every delimiter is emitted; the -//! parser pairs them and falls back to literal text on mismatches. Spans are -//! stored as 0-indexed byte offsets. -//! -//! Multi-line constructs (`{{{ }}}`, `{{$ }}$`, `%%+ +%%`) flip a -//! `BlockMode` so subsequent lines are treated as raw content until the -//! matching closer is seen. - -use std::collections::HashMap; - -use crate::ast::{CheckboxState, Keyword, ListSymbol, Position, Span}; -use crate::listsyms::ListSyms; -use crate::syntax::{Lexer, TokenStream}; - -/// A single vimwiki token. The `kind` carries the variant, `span` carries -/// 0-indexed byte offsets into the source. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct VimwikiToken { - pub kind: VimwikiTokenKind, - pub span: Span, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum VimwikiTokenKind { - // ----- Line structure ----- - /// End of a non-empty line (`\n`). - Newline, - /// A line that contained only whitespace. Replaces both the (empty) - /// content and the trailing newline. - BlankLine, - - // ----- Headings ----- - /// Opening `=`s of a heading. `centered` is true when the line had - /// leading whitespace before the `=` run. - HeadingOpen { - level: u8, - centered: bool, - }, - /// Closing `=`s on the same line as `HeadingOpen`. - HeadingClose, - - // ----- Bare structure ----- - HorizontalRule, - BlockquoteMarker, - BlockquoteIndent, - - // ----- Lists ----- - ListMarker { - symbol: ListSymbol, - indent: u32, - }, - Checkbox(CheckboxState), - - // ----- Definition lists ----- - DefinitionTermMarker, - - // ----- Tables ----- - /// `|` cell separator. - TableSep, - /// A header-separator row, e.g. `|---|---|`. Carries per-cell - /// Markdown-style alignment markers (`:--`, `--:`, `:--:`). - TableHeaderRow(Vec), - /// A cell whose only content is `>`, meaning "merge with cell to the left". - TableColSpan, - /// A cell whose only content is `\/`, meaning "merge with cell above". - TableRowSpan, - - // ----- Multi-line fences ----- - PreformattedOpen { - language: Option, - attrs: HashMap, - /// Verbatim text after `{{{` (trailing whitespace trimmed), preserved - /// so the HTML renderer can reproduce vimwiki's raw `

    ` attrs.
    -        raw: String,
    -    },
    -    PreformattedClose,
    -    PreformattedLine(String),
    -
    -    MathBlockOpen {
    -        environment: Option,
    -    },
    -    MathBlockClose,
    -    MathBlockLine(String),
    -
    -    /// Single-line comment: `%% ...`
    -    CommentLine(String),
    -    /// Opening of a multi-line comment: `%%+`
    -    CommentMultiOpen,
    -    CommentMultiClose,
    -    CommentMultiLine(String),
    -
    -    // ----- Tags -----
    -    /// `:tag1:tag2:` — colon-delimited tag line. The lexer treats this as
    -    /// block-level (must be the entire trimmed content of the line).
    -    /// Scope (file / heading / standalone) is the parser's job.
    -    Tag(Vec),
    -
    -    // ----- Page placeholders -----
    -    PlaceholderTitle(Option),
    -    PlaceholderNohtml,
    -    PlaceholderTemplate(Option),
    -    PlaceholderDate(Option),
    -
    -    // ----- Inline content -----
    -    Text(String),
    -    BoldDelim,
    -    ItalicDelim,
    -    StrikethroughDelim,
    -    SuperscriptDelim,
    -    SubscriptDelim,
    -    Code(String),
    -    MathInline(String),
    -    Keyword(Keyword),
    -
    -    // ----- Links / transclusions -----
    -    WikiLinkOpen,
    -    WikiLinkClose,
    -    WikiLinkSep,
    -    TransclusionOpen,
    -    TransclusionClose,
    -    TransclusionSep,
    -    RawUrl(String),
    -
    -    /// Lex error — never aborts the whole document.
    -    Error(String),
    -}
    -
    -#[derive(Debug, Default, Clone)]
    -pub struct VimwikiLexer {
    -    listsyms: ListSyms,
    -}
    -
    -impl VimwikiLexer {
    -    pub fn new() -> Self {
    -        Self::default()
    -    }
    -
    -    /// Recognise checkbox glyphs from a custom palette
    -    /// (vimwiki's `g:vimwiki_listsyms`) instead of the default `" .oOX"`.
    -    pub fn with_listsyms(mut self, listsyms: ListSyms) -> Self {
    -        self.listsyms = listsyms;
    -        self
    -    }
    -}
    -
    -impl Lexer for VimwikiLexer {
    -    type Token = VimwikiToken;
    -
    -    fn lex(&self, text: &str) -> TokenStream {
    -        let mut state = LexState::new(text, &self.listsyms);
    -        state.run();
    -        TokenStream::from_vec(state.tokens)
    -    }
    -}
    -
    -// ===== Internal state machine =====
    -
    -#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    -enum BlockMode {
    -    Normal,
    -    Preformatted,
    -    MathBlock,
    -    MultilineComment,
    -}
    -
    -struct LexState<'src> {
    -    src: &'src str,
    -    /// Byte offset of the start of the current line.
    -    line_start_offset: usize,
    -    /// Index of the current line (0-based).
    -    line: u32,
    -    tokens: Vec,
    -    mode: BlockMode,
    -    listsyms: &'src ListSyms,
    -}
    -
    -impl<'src> LexState<'src> {
    -    fn new(src: &'src str, listsyms: &'src ListSyms) -> Self {
    -        Self {
    -            src,
    -            line_start_offset: 0,
    -            line: 0,
    -            tokens: Vec::new(),
    -            mode: BlockMode::Normal,
    -            listsyms,
    -        }
    -    }
    -
    -    fn run(&mut self) {
    -        while self.line_start_offset < self.src.len() {
    -            let line_text = self.current_line();
    -            let line_len = line_text.len();
    -
    -            match self.mode {
    -                BlockMode::Normal => self.lex_normal_line(line_text),
    -                BlockMode::Preformatted => self.lex_preformatted_line(line_text),
    -                BlockMode::MathBlock => self.lex_math_block_line(line_text),
    -                BlockMode::MultilineComment => self.lex_multi_comment_line(line_text),
    -            }
    -
    -            // Skip past the line's trailing newline (if any) and advance line counters.
    -            let after_content = self.line_start_offset + line_len;
    -            if after_content < self.src.len() && self.src.as_bytes()[after_content] == b'\n' {
    -                self.line_start_offset = after_content + 1;
    -                self.line += 1;
    -            } else {
    -                self.line_start_offset = after_content;
    -            }
    -        }
    -    }
    -
    -    /// Slice of the current line, not including the trailing newline.
    -    fn current_line(&self) -> &'src str {
    -        let rest = &self.src[self.line_start_offset..];
    -        match rest.find('\n') {
    -            Some(idx) => &rest[..idx],
    -            None => rest,
    -        }
    -    }
    -
    -    /// Position at byte offset `col` within the current line.
    -    fn pos_in_line(&self, col: u32) -> Position {
    -        Position {
    -            line: self.line,
    -            column: col,
    -            offset: self.line_start_offset + col as usize,
    -        }
    -    }
    -
    -    fn line_start_pos(&self) -> Position {
    -        self.pos_in_line(0)
    -    }
    -
    -    fn line_end_pos(&self, line_text: &str) -> Position {
    -        self.pos_in_line(line_text.len() as u32)
    -    }
    -
    -    fn push(&mut self, kind: VimwikiTokenKind, span: Span) {
    -        self.tokens.push(VimwikiToken { kind, span });
    -    }
    -
    -    /// Emit a token whose span runs from byte-col `start_col` to `end_col`
    -    /// within the current line.
    -    fn emit(&mut self, kind: VimwikiTokenKind, start_col: u32, end_col: u32) {
    -        let span = Span::new(self.pos_in_line(start_col), self.pos_in_line(end_col));
    -        self.push(kind, span);
    -    }
    -
    -    fn emit_newline(&mut self, line_text: &str) {
    -        let nl_col = line_text.len() as u32;
    -        // Newline is just \n at column = line_text.len() spanning one byte.
    -        let span = Span::new(self.pos_in_line(nl_col), self.pos_in_line(nl_col + 1));
    -        self.push(VimwikiTokenKind::Newline, span);
    -    }
    -
    -    // ===== Mode dispatch =====
    -
    -    fn lex_preformatted_line(&mut self, line: &str) {
    -        // Closing fence: line whose first non-space content is `}}}`.
    -        if line.trim() == "}}}" {
    -            let start_col = line.find("}}}").unwrap() as u32;
    -            self.emit(
    -                VimwikiTokenKind::PreformattedClose,
    -                start_col,
    -                start_col + 3,
    -            );
    -            self.mode = BlockMode::Normal;
    -            self.emit_newline(line);
    -            return;
    -        }
    -        let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
    -        self.push(VimwikiTokenKind::PreformattedLine(line.to_owned()), span);
    -        self.emit_newline(line);
    -    }
    -
    -    fn lex_math_block_line(&mut self, line: &str) {
    -        if line.trim() == "}}$" {
    -            let start_col = line.find("}}$").unwrap() as u32;
    -            self.emit(VimwikiTokenKind::MathBlockClose, start_col, start_col + 3);
    -            self.mode = BlockMode::Normal;
    -            self.emit_newline(line);
    -            return;
    -        }
    -        let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
    -        self.push(VimwikiTokenKind::MathBlockLine(line.to_owned()), span);
    -        self.emit_newline(line);
    -    }
    -
    -    fn lex_multi_comment_line(&mut self, line: &str) {
    -        // Closer is `+%%`, possibly with content before it on the same line.
    -        if let Some(idx) = line.find("+%%") {
    -            let prefix = &line[..idx];
    -            if !prefix.is_empty() {
    -                let span = Span::new(self.pos_in_line(0), self.pos_in_line(prefix.len() as u32));
    -                self.push(VimwikiTokenKind::CommentMultiLine(prefix.to_owned()), span);
    -            }
    -            let close_col = idx as u32;
    -            self.emit(
    -                VimwikiTokenKind::CommentMultiClose,
    -                close_col,
    -                close_col + 3,
    -            );
    -            self.mode = BlockMode::Normal;
    -            self.emit_newline(line);
    -            return;
    -        }
    -        let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
    -        self.push(VimwikiTokenKind::CommentMultiLine(line.to_owned()), span);
    -        self.emit_newline(line);
    -    }
    -
    -    fn lex_normal_line(&mut self, line: &str) {
    -        // Whitespace-only line → BlankLine (replaces both content and the
    -        // following newline).
    -        if line.bytes().all(|b| b == b' ' || b == b'\t') {
    -            let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
    -            self.push(VimwikiTokenKind::BlankLine, span);
    -            // Also account for the newline byte if present.
    -            // We don't emit a Newline because BlankLine subsumes both.
    -            return;
    -        }
    -
    -        // Block patterns in priority order. The first match wins.
    -        if self.try_lex_multi_comment_open(line)
    -            || self.try_lex_single_comment(line)
    -            || self.try_lex_preformatted_open(line)
    -            || self.try_lex_math_block_open(line)
    -            || self.try_lex_placeholder(line)
    -            || self.try_lex_horizontal_rule(line)
    -            || self.try_lex_heading(line)
    -            || self.try_lex_tag(line)
    -            || self.try_lex_table_row(line)
    -            || self.try_lex_blockquote(line)
    -            || self.try_lex_list_item(line)
    -            || self.try_lex_definition_term(line)
    -        {
    -            return;
    -        }
    -
    -        // Default: paragraph line — lex inline.
    -        self.lex_inline(line, 0, line.len() as u32);
    -        self.emit_newline(line);
    -    }
    -
    -    // ===== Block patterns =====
    -
    -    fn try_lex_multi_comment_open(&mut self, line: &str) -> bool {
    -        let trimmed = line.trim_start();
    -        if !trimmed.starts_with("%%+") {
    -            return false;
    -        }
    -        let indent = (line.len() - trimmed.len()) as u32;
    -        self.emit(VimwikiTokenKind::CommentMultiOpen, indent, indent + 3);
    -        // Same line might also contain `+%%` (one-line multi).
    -        let after_open = (indent + 3) as usize;
    -        let rest = &line[after_open..];
    -        if let Some(end_rel) = rest.find("+%%") {
    -            let prefix = &rest[..end_rel];
    -            if !prefix.is_empty() {
    -                let p_start = indent + 3;
    -                let p_end = p_start + prefix.len() as u32;
    -                self.push(
    -                    VimwikiTokenKind::CommentMultiLine(prefix.to_owned()),
    -                    Span::new(self.pos_in_line(p_start), self.pos_in_line(p_end)),
    -                );
    -            }
    -            let close_col = (after_open + end_rel) as u32;
    -            self.emit(
    -                VimwikiTokenKind::CommentMultiClose,
    -                close_col,
    -                close_col + 3,
    -            );
    -            self.emit_newline(line);
    -        } else {
    -            // Capture remainder of this line as content, then enter
    -            // multiline-comment mode for following lines.
    -            if !rest.is_empty() {
    -                let p_start = indent + 3;
    -                let p_end = p_start + rest.len() as u32;
    -                self.push(
    -                    VimwikiTokenKind::CommentMultiLine(rest.to_owned()),
    -                    Span::new(self.pos_in_line(p_start), self.pos_in_line(p_end)),
    -                );
    -            }
    -            self.emit_newline(line);
    -            self.mode = BlockMode::MultilineComment;
    -        }
    -        true
    -    }
    -
    -    fn try_lex_single_comment(&mut self, line: &str) -> bool {
    -        let trimmed = line.trim_start();
    -        if !trimmed.starts_with("%%") || trimmed.starts_with("%%+") {
    -            return false;
    -        }
    -        let indent = (line.len() - trimmed.len()) as u32;
    -        let content = &trimmed[2..]; // skip "%%"
    -        let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line));
    -        self.push(VimwikiTokenKind::CommentLine(content.to_owned()), span);
    -        self.emit_newline(line);
    -        true
    -    }
    -
    -    fn try_lex_preformatted_open(&mut self, line: &str) -> bool {
    -        let trimmed = line.trim_start();
    -        if !trimmed.starts_with("{{{") {
    -            return false;
    -        }
    -        let indent = (line.len() - trimmed.len()) as u32;
    -        // `{{{[lang][;key=val[;key=val]*]?` style. Vimwiki accepts a single
    -        // word after the fence (language hint) and/or class/attribute key=val
    -        // pairs separated by spaces. Keep parsing forgiving.
    -        let after = trimmed[3..].trim();
    -        let (language, attrs) = parse_fence_attrs(after);
    -        // vimwiki keeps everything after `{{{` (minus trailing whitespace) and
    -        // drops it verbatim into the `
    ` tag, so capture it unparsed.
    -        let raw = trimmed[3..].trim_end().to_string();
    -        let kind = VimwikiTokenKind::PreformattedOpen {
    -            language,
    -            attrs,
    -            raw,
    -        };
    -        let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line));
    -        self.push(kind, span);
    -        self.mode = BlockMode::Preformatted;
    -        self.emit_newline(line);
    -        true
    -    }
    -
    -    fn try_lex_math_block_open(&mut self, line: &str) -> bool {
    -        let trimmed = line.trim_start();
    -        if !trimmed.starts_with("{{$") {
    -            return false;
    -        }
    -        let indent = (line.len() - trimmed.len()) as u32;
    -        let after = trimmed[3..].trim();
    -        // `{{$%env%` — environment is between `%`s.
    -        let environment = after
    -            .strip_prefix('%')
    -            .and_then(|s| s.strip_suffix('%'))
    -            .map(str::to_owned);
    -        let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line));
    -        self.push(VimwikiTokenKind::MathBlockOpen { environment }, span);
    -        self.mode = BlockMode::MathBlock;
    -        self.emit_newline(line);
    -        true
    -    }
    -
    -    fn try_lex_placeholder(&mut self, line: &str) -> bool {
    -        // Placeholders must start at column 0 (no leading whitespace).
    -        if !line.starts_with('%') {
    -            return false;
    -        }
    -        let kind = if let Some(rest) = line.strip_prefix("%title") {
    -            let value = rest.trim();
    -            let v = if value.is_empty() {
    -                None
    -            } else {
    -                Some(value.to_owned())
    -            };
    -            VimwikiTokenKind::PlaceholderTitle(v)
    -        } else if line.trim_end() == "%nohtml" {
    -            VimwikiTokenKind::PlaceholderNohtml
    -        } else if let Some(rest) = line.strip_prefix("%template") {
    -            let value = rest.trim();
    -            let v = if value.is_empty() {
    -                None
    -            } else {
    -                Some(value.to_owned())
    -            };
    -            VimwikiTokenKind::PlaceholderTemplate(v)
    -        } else if let Some(rest) = line.strip_prefix("%date") {
    -            let value = rest.trim();
    -            let v = if value.is_empty() {
    -                None
    -            } else {
    -                Some(value.to_owned())
    -            };
    -            VimwikiTokenKind::PlaceholderDate(v)
    -        } else {
    -            return false;
    -        };
    -        let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
    -        self.push(kind, span);
    -        self.emit_newline(line);
    -        true
    -    }
    -
    -    fn try_lex_horizontal_rule(&mut self, line: &str) -> bool {
    -        let trimmed = line.trim();
    -        if trimmed.len() < 4 || !trimmed.bytes().all(|b| b == b'-') {
    -            return false;
    -        }
    -        // Four or more dashes. Allow surrounding whitespace.
    -        let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
    -        self.push(VimwikiTokenKind::HorizontalRule, span);
    -        self.emit_newline(line);
    -        true
    -    }
    -
    -    fn try_lex_heading(&mut self, line: &str) -> bool {
    -        let bytes = line.as_bytes();
    -        let leading_ws = bytes
    -            .iter()
    -            .take_while(|&&b| b == b' ' || b == b'\t')
    -            .count();
    -        let centered = leading_ws > 0;
    -
    -        // Count `=`s after leading whitespace.
    -        let mut level = 0usize;
    -        while level < 6 && leading_ws + level < bytes.len() && bytes[leading_ws + level] == b'=' {
    -            level += 1;
    -        }
    -        if level == 0 {
    -            return false;
    -        }
    -        // vimwiki accepts both spaced (`== H ==`) and spaceless (`==H==`)
    -        // headings, so we don't require a space after the opening `=`s. The
    -        // title is still trimmed of one optional space per side below.
    -
    -        // Trailing `=`s of the same level.
    -        let trimmed_end = line.trim_end_matches([' ', '\t']);
    -        let trailing_eqs = trimmed_end
    -            .as_bytes()
    -            .iter()
    -            .rev()
    -            .take_while(|&&b| b == b'=')
    -            .count();
    -        if trailing_eqs != level {
    -            return false;
    -        }
    -
    -        let title_start = leading_ws + level;
    -        let title_end = trimmed_end.len() - trailing_eqs;
    -        if title_end <= title_start {
    -            return false;
    -        }
    -
    -        let open_col = leading_ws as u32;
    -        let open_end = (leading_ws + level) as u32;
    -        self.emit(
    -            VimwikiTokenKind::HeadingOpen {
    -                level: level as u8,
    -                centered,
    -            },
    -            open_col,
    -            open_end,
    -        );
    -
    -        // Inline content between markers (trim one space on each side if present).
    -        let mut t_start = title_start;
    -        let mut t_end = title_end;
    -        if t_start < t_end && bytes[t_start] == b' ' {
    -            t_start += 1;
    -        }
    -        if t_end > t_start && bytes[t_end - 1] == b' ' {
    -            t_end -= 1;
    -        }
    -        if t_end > t_start {
    -            self.lex_inline(&line[t_start..t_end], t_start as u32, t_end as u32);
    -        }
    -
    -        let close_col = title_end as u32;
    -        let close_end = (title_end + trailing_eqs) as u32;
    -        self.emit(VimwikiTokenKind::HeadingClose, close_col, close_end);
    -        self.emit_newline(line);
    -        true
    -    }
    -
    -    fn try_lex_table_row(&mut self, line: &str) -> bool {
    -        let trimmed = line.trim();
    -        if !trimmed.starts_with('|') || !trimmed.ends_with('|') || trimmed.len() < 2 {
    -            return false;
    -        }
    -
    -        // Header separator row: every cell is `-`s (with optional `:`
    -        // anchors at one or both ends for alignment) or whitespace.
    -        if trimmed
    -            .split('|')
    -            .filter(|s| !s.is_empty())
    -            .all(is_sep_cell)
    -        {
    -            let aligns: Vec = trimmed
    -                .split('|')
    -                .filter(|s| !s.is_empty())
    -                .map(parse_sep_alignment)
    -                .collect();
    -            let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
    -            self.push(VimwikiTokenKind::TableHeaderRow(aligns), span);
    -            self.emit_newline(line);
    -            return true;
    -        }
    -
    -        // Walk the line and emit TableSep tokens at each `|`, with cell
    -        // content lexed inline between them.
    -        let bytes = line.as_bytes();
    -        let mut i = 0usize;
    -        // Skip leading whitespace.
    -        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
    -            i += 1;
    -        }
    -        // Trailing whitespace bound.
    -        let mut end = bytes.len();
    -        while end > i && (bytes[end - 1] == b' ' || bytes[end - 1] == b'\t') {
    -            end -= 1;
    -        }
    -
    -        // Collect cell boundaries.
    -        let mut sep_positions = Vec::new();
    -        let mut k = i;
    -        while k < end {
    -            if bytes[k] == b'|' {
    -                sep_positions.push(k);
    -            }
    -            k += 1;
    -        }
    -
    -        if sep_positions.len() < 2 {
    -            return false;
    -        }
    -
    -        for window in sep_positions.windows(2) {
    -            let sep_col = window[0] as u32;
    -            self.emit(VimwikiTokenKind::TableSep, sep_col, sep_col + 1);
    -            let cell_start = window[0] + 1;
    -            let cell_end = window[1];
    -            let cell_text = &line[cell_start..cell_end];
    -            let cell_trimmed = cell_text.trim();
    -            match cell_trimmed {
    -                ">" => {
    -                    let s = (cell_start + cell_text.find('>').unwrap()) as u32;
    -                    self.emit(VimwikiTokenKind::TableColSpan, s, s + 1);
    -                }
    -                "\\/" => {
    -                    let s = (cell_start + cell_text.find("\\/").unwrap()) as u32;
    -                    self.emit(VimwikiTokenKind::TableRowSpan, s, s + 2);
    -                }
    -                _ if !cell_trimmed.is_empty() => {
    -                    self.lex_inline(cell_text, cell_start as u32, cell_end as u32);
    -                }
    -                _ => {}
    -            }
    -        }
    -        // Emit the last separator.
    -        let last_sep = *sep_positions.last().unwrap() as u32;
    -        self.emit(VimwikiTokenKind::TableSep, last_sep, last_sep + 1);
    -        self.emit_newline(line);
    -        true
    -    }
    -
    -    fn try_lex_blockquote(&mut self, line: &str) -> bool {
    -        // `>` prefix style.
    -        let trimmed = line.trim_start();
    -        let indent = (line.len() - trimmed.len()) as u32;
    -        if let Some(rest) =
    -            trimmed
    -                .strip_prefix("> ")
    -                .or_else(|| if trimmed == ">" { Some("") } else { None })
    -        {
    -            self.emit(VimwikiTokenKind::BlockquoteMarker, indent, indent + 1);
    -            if !rest.is_empty() {
    -                let inline_start = indent + 2;
    -                let inline_end = inline_start + rest.len() as u32;
    -                self.lex_inline(rest, inline_start, inline_end);
    -            }
    -            self.emit_newline(line);
    -            return true;
    -        }
    -
    -        // 4-space indent style. To avoid swallowing list items, only treat as
    -        // a blockquote when the indented content does NOT start with a list
    -        // marker.
    -        if let Some(rest) = line.strip_prefix("    ") {
    -            if rest.chars().next().is_none_or(|c| c == ' ' || c == '\t')
    -                || looks_like_list_marker(rest)
    -            {
    -                return false;
    -            }
    -            self.emit(VimwikiTokenKind::BlockquoteIndent, 0, 4);
    -            self.lex_inline(rest, 4, line.len() as u32);
    -            self.emit_newline(line);
    -            return true;
    -        }
    -        false
    -    }
    -
    -    fn try_lex_list_item(&mut self, line: &str) -> bool {
    -        let trimmed = line.trim_start();
    -        let indent_bytes = line.len() - trimmed.len();
    -        let indent = indent_bytes as u32;
    -
    -        let (symbol, marker_len) = match list_marker_at(trimmed) {
    -            Some(v) => v,
    -            None => return false,
    -        };
    -
    -        // Marker must be followed by a space.
    -        if marker_len >= trimmed.len() || trimmed.as_bytes()[marker_len] != b' ' {
    -            return false;
    -        }
    -
    -        let marker_start = indent;
    -        let marker_end = indent + marker_len as u32;
    -        self.emit(
    -            VimwikiTokenKind::ListMarker { symbol, indent },
    -            marker_start,
    -            marker_end,
    -        );
    -
    -        // Past marker + the single required space.
    -        let mut cursor = marker_len + 1;
    -        let after = &trimmed[cursor..];
    -
    -        // Optional checkbox `[ ]` / `[.]` / `[o]` / `[O]` / `[X]` / `[-]`,
    -        // followed by a space.
    -        if let Some((cb, cb_len)) = checkbox_at(after, self.listsyms) {
    -            let cb_start = indent + cursor as u32;
    -            let cb_end = cb_start + cb_len as u32;
    -            self.emit(VimwikiTokenKind::Checkbox(cb), cb_start, cb_end);
    -            cursor += cb_len;
    -            // Eat the trailing space if present.
    -            if cursor < trimmed.len() && trimmed.as_bytes()[cursor] == b' ' {
    -                cursor += 1;
    -            }
    -        }
    -
    -        let inline_start = indent + cursor as u32;
    -        let inline_end = line.len() as u32;
    -        if (inline_end as usize) > (inline_start as usize) {
    -            let abs_start = self.line_start_offset + inline_start as usize;
    -            let abs_end = self.line_start_offset + inline_end as usize;
    -            let slice = &self.src[abs_start..abs_end];
    -            self.lex_inline(slice, inline_start, inline_end);
    -        }
    -        self.emit_newline(line);
    -        true
    -    }
    -
    -    /// `:tag1:tag2:tag3:` — colon-delimited tag line (block-level).
    -    ///
    -    /// The whole trimmed line must match: starts and ends with `:`, with
    -    /// non-empty whitespace-free names between adjacent colons. Empty
    -    /// segments or whitespace inside tag names disqualify the line.
    -    /// Doesn't conflict with `::` (definition list marker) because that
    -    /// pattern requires text *before* the `::`.
    -    fn try_lex_tag(&mut self, line: &str) -> bool {
    -        let trimmed = line.trim();
    -        if trimmed.len() < 3 || !trimmed.starts_with(':') || !trimmed.ends_with(':') {
    -            return false;
    -        }
    -        // `:tag1:tag2:` splits as `["", "tag1", "tag2", ""]`; the empty
    -        // strings at the ends are expected, everything in between must be
    -        // non-empty and contain no whitespace.
    -        let parts: Vec<&str> = trimmed.split(':').collect();
    -        if parts.first() != Some(&"") || parts.last() != Some(&"") {
    -            return false;
    -        }
    -        let names = &parts[1..parts.len() - 1];
    -        if names.is_empty()
    -            || names
    -                .iter()
    -                .any(|t| t.is_empty() || t.chars().any(char::is_whitespace))
    -        {
    -            return false;
    -        }
    -        let indent = (line.len() - line.trim_start().len()) as u32;
    -        let end_col = indent + trimmed.len() as u32;
    -        self.emit(
    -            VimwikiTokenKind::Tag(names.iter().map(|s| (*s).to_owned()).collect()),
    -            indent,
    -            end_col,
    -        );
    -        self.emit_newline(line);
    -        true
    -    }
    -
    -    fn try_lex_definition_term(&mut self, line: &str) -> bool {
    -        // `Term:: Definition` or `Term::` (continuation lines follow).
    -        // The `::` must be preceded by non-`:` text and followed by either
    -        // EOL or a single space + content.
    -        let bytes = line.as_bytes();
    -        let mut i = 0usize;
    -        let mut found = None;
    -        while i + 1 < bytes.len() {
    -            if bytes[i] == b':' && bytes[i + 1] == b':' {
    -                // Must not be preceded by another `:` (avoid `:::`).
    -                if i > 0 && bytes[i - 1] == b':' {
    -                    i += 1;
    -                    continue;
    -                }
    -                // Must not be followed by another `:`.
    -                if i + 2 < bytes.len() && bytes[i + 2] == b':' {
    -                    i += 1;
    -                    continue;
    -                }
    -                found = Some(i);
    -                break;
    -            }
    -            i += 1;
    -        }
    -        let split = match found {
    -            Some(s) if s > 0 => s,
    -            _ => return false,
    -        };
    -
    -        // Term inline.
    -        self.lex_inline(&line[..split], 0, split as u32);
    -        let marker_col = split as u32;
    -        self.emit(
    -            VimwikiTokenKind::DefinitionTermMarker,
    -            marker_col,
    -            marker_col + 2,
    -        );
    -
    -        let after = &line[split + 2..];
    -        if !after.is_empty() {
    -            let leading = after
    -                .bytes()
    -                .take_while(|b| *b == b' ' || *b == b'\t')
    -                .count();
    -            if leading > 0 {
    -                // Treat the post-`::` whitespace as a separator; lex the rest as inline.
    -                let inline_start = (split + 2 + leading) as u32;
    -                let inline_end = line.len() as u32;
    -                if inline_end > inline_start {
    -                    self.lex_inline(&after[leading..], inline_start, inline_end);
    -                }
    -            } else {
    -                // No space after `::`: still try to lex inline.
    -                let inline_start = (split + 2) as u32;
    -                self.lex_inline(after, inline_start, line.len() as u32);
    -            }
    -        }
    -        self.emit_newline(line);
    -        true
    -    }
    -
    -    // ===== Inline pass =====
    -    //
    -    // Inline content lives at byte offsets [start_col, end_col) within the
    -    // current line. Span columns are relative to the line.
    -
    -    fn lex_inline(&mut self, slice: &str, line_col_start: u32, _line_col_end: u32) {
    -        let bytes = slice.as_bytes();
    -        let mut buf = String::new();
    -        let mut buf_start: Option = None;
    -        let mut i = 0usize;
    -
    -        let flush = |this: &mut Self, buf: &mut String, buf_start: &mut Option, end: u32| {
    -            if !buf.is_empty() {
    -                let s = buf_start.take().unwrap();
    -                this.emit(VimwikiTokenKind::Text(std::mem::take(buf)), s, end);
    -            }
    -        };
    -
    -        while i < bytes.len() {
    -            let abs_col = line_col_start + i as u32;
    -            let rest = &slice[i..];
    -
    -            // 1) Multi-char delimiters and constructs (longest match wins).
    -            if let Some(after_open) = rest.strip_prefix("[[") {
    -                flush(self, &mut buf, &mut buf_start, abs_col);
    -                self.emit(VimwikiTokenKind::WikiLinkOpen, abs_col, abs_col + 2);
    -                i += 2;
    -                // Lex until the closing `]]`. A single `]` inside the body —
    -                // e.g. a `[TICKET-1]` in the description — is literal text, so
    -                // we balance inner `[ ]` rather than stopping at the first `]]`.
    -                let close_abs_in_slice = find_wikilink_close(after_open).map(|c| i + c);
    -                let inner_end = close_abs_in_slice.unwrap_or(slice.len());
    -                self.lex_link_body(slice, i, line_col_start, inner_end, true);
    -                i = inner_end;
    -                if close_abs_in_slice.is_some() {
    -                    let close_col = line_col_start + i as u32;
    -                    self.emit(VimwikiTokenKind::WikiLinkClose, close_col, close_col + 2);
    -                    i += 2;
    -                }
    -                continue;
    -            }
    -            if rest.starts_with("{{{") {
    -                // Inline {{{ is rare; fall through as text.
    -                buf_start.get_or_insert(abs_col);
    -                buf.push('{');
    -                i += 1;
    -                continue;
    -            }
    -            if rest.starts_with("{{") {
    -                flush(self, &mut buf, &mut buf_start, abs_col);
    -                self.emit(VimwikiTokenKind::TransclusionOpen, abs_col, abs_col + 2);
    -                i += 2;
    -                let close_rel = slice[i..].find("}}");
    -                let inner_end_rel = close_rel.map(|c| i + c).unwrap_or(slice.len());
    -                self.lex_link_body(slice, i, line_col_start, inner_end_rel, false);
    -                i = inner_end_rel;
    -                if close_rel.is_some() {
    -                    let close_col = line_col_start + i as u32;
    -                    self.emit(
    -                        VimwikiTokenKind::TransclusionClose,
    -                        close_col,
    -                        close_col + 2,
    -                    );
    -                    i += 2;
    -                }
    -                continue;
    -            }
    -            if rest.starts_with("~~") {
    -                flush(self, &mut buf, &mut buf_start, abs_col);
    -                self.emit(VimwikiTokenKind::StrikethroughDelim, abs_col, abs_col + 2);
    -                i += 2;
    -                continue;
    -            }
    -            if rest.starts_with(",,") {
    -                flush(self, &mut buf, &mut buf_start, abs_col);
    -                self.emit(VimwikiTokenKind::SubscriptDelim, abs_col, abs_col + 2);
    -                i += 2;
    -                continue;
    -            }
    -
    -            // 2) Inline code: `...`
    -            if bytes[i] == b'`' {
    -                if let Some(end_rel) = slice[i + 1..].find('`') {
    -                    flush(self, &mut buf, &mut buf_start, abs_col);
    -                    let content = &slice[i + 1..i + 1 + end_rel];
    -                    let span_end = abs_col + (end_rel + 2) as u32;
    -                    self.emit(
    -                        VimwikiTokenKind::Code(content.to_owned()),
    -                        abs_col,
    -                        span_end,
    -                    );
    -                    i += end_rel + 2;
    -                    continue;
    -                }
    -            }
    -
    -            // 3) Inline math: $...$  (single $ on each side; bail to text if unmatched)
    -            if bytes[i] == b'$' {
    -                if let Some(end_rel) = slice[i + 1..].find('$') {
    -                    flush(self, &mut buf, &mut buf_start, abs_col);
    -                    let content = &slice[i + 1..i + 1 + end_rel];
    -                    let span_end = abs_col + (end_rel + 2) as u32;
    -                    self.emit(
    -                        VimwikiTokenKind::MathInline(content.to_owned()),
    -                        abs_col,
    -                        span_end,
    -                    );
    -                    i += end_rel + 2;
    -                    continue;
    -                }
    -            }
    -
    -            // 4) Single-char delimiters.
    -            let single = match bytes[i] {
    -                b'*' => Some(VimwikiTokenKind::BoldDelim),
    -                b'_' => Some(VimwikiTokenKind::ItalicDelim),
    -                b'^' => Some(VimwikiTokenKind::SuperscriptDelim),
    -                _ => None,
    -            };
    -            if let Some(kind) = single {
    -                flush(self, &mut buf, &mut buf_start, abs_col);
    -                self.emit(kind, abs_col, abs_col + 1);
    -                i += 1;
    -                continue;
    -            }
    -
    -            // 5) Raw URL — at a word boundary.
    -            if is_word_boundary(bytes, i) {
    -                if let Some(url_len) = match_url(rest) {
    -                    flush(self, &mut buf, &mut buf_start, abs_col);
    -                    let url = &rest[..url_len];
    -                    self.emit(
    -                        VimwikiTokenKind::RawUrl(url.to_owned()),
    -                        abs_col,
    -                        abs_col + url_len as u32,
    -                    );
    -                    i += url_len;
    -                    continue;
    -                }
    -                // 6) Keyword.
    -                if let Some((kw_len, kw)) = match_keyword(rest) {
    -                    flush(self, &mut buf, &mut buf_start, abs_col);
    -                    self.emit(
    -                        VimwikiTokenKind::Keyword(kw),
    -                        abs_col,
    -                        abs_col + kw_len as u32,
    -                    );
    -                    i += kw_len;
    -                    continue;
    -                }
    -            }
    -
    -            // 7) Default: accumulate into Text. Walk to next byte boundary.
    -            let ch = slice[i..].chars().next().unwrap();
    -            let ch_len = ch.len_utf8();
    -            buf_start.get_or_insert(abs_col);
    -            buf.push(ch);
    -            i += ch_len;
    -        }
    -
    -        let end_col = line_col_start + slice.len() as u32;
    -        flush(self, &mut buf, &mut buf_start, end_col);
    -    }
    -
    -    /// Lex inline content within `[[...]]` or `{{...}}`, where `|` is a
    -    /// separator rather than literal pipe. `is_wikilink` chooses the token
    -    /// variant for the separator.
    -    fn lex_link_body(
    -        &mut self,
    -        slice: &str,
    -        start: usize,
    -        line_col_start: u32,
    -        end: usize,
    -        is_wikilink: bool,
    -    ) {
    -        let bytes = slice.as_bytes();
    -        let mut buf = String::new();
    -        let mut buf_start: Option = None;
    -        let mut i = start;
    -
    -        let flush =
    -            |this: &mut Self, buf: &mut String, buf_start: &mut Option, end_col: u32| {
    -                if !buf.is_empty() {
    -                    let s = buf_start.take().unwrap();
    -                    this.emit(VimwikiTokenKind::Text(std::mem::take(buf)), s, end_col);
    -                }
    -            };
    -
    -        while i < end {
    -            let abs_col = line_col_start + i as u32;
    -            if bytes[i] == b'|' {
    -                flush(self, &mut buf, &mut buf_start, abs_col);
    -                let kind = if is_wikilink {
    -                    VimwikiTokenKind::WikiLinkSep
    -                } else {
    -                    VimwikiTokenKind::TransclusionSep
    -                };
    -                self.emit(kind, abs_col, abs_col + 1);
    -                i += 1;
    -                continue;
    -            }
    -            let ch = slice[i..].chars().next().unwrap();
    -            let ch_len = ch.len_utf8();
    -            buf_start.get_or_insert(abs_col);
    -            buf.push(ch);
    -            i += ch_len;
    -        }
    -        flush(self, &mut buf, &mut buf_start, line_col_start + end as u32);
    -    }
    -}
    -
    -// ===== Helpers =====
    -
    -/// Parse the bit after `{{{` on a fence line. Format is forgiving:
    -/// `lang` (single word) followed by optional `key=value` pairs separated
    -/// by spaces.
    -/// Byte offset of the closing `]]` for a wikilink body that begins at the start
    -/// of `s` (just after the opening `[[`). Inner `[ ]` pairs are balanced so a
    -/// bracketed description like `[[page|Task [B-1]]]` keeps the `[B-1]` and closes
    -/// at the final `]]`. A stray single `]` (no matching `[`) is treated as literal
    -/// body text. Returns `None` if no closing `]]` is found.
    -fn find_wikilink_close(s: &str) -> Option {
    -    let b = s.as_bytes();
    -    let mut depth: u32 = 0;
    -    let mut i = 0;
    -    while i < b.len() {
    -        match b[i] {
    -            b'[' => depth += 1,
    -            b']' if depth > 0 => depth -= 1, // closes an inner '['
    -            b']' if i + 1 < b.len() && b[i + 1] == b']' => return Some(i),
    -            _ => {}
    -        }
    -        i += 1;
    -    }
    -    None
    -}
    -
    -fn parse_fence_attrs(s: &str) -> (Option, HashMap) {
    -    let mut attrs = HashMap::new();
    -    let mut language: Option = None;
    -    for tok in s.split_whitespace() {
    -        if let Some(eq) = tok.find('=') {
    -            let (k, v) = tok.split_at(eq);
    -            let v = &v[1..];
    -            // Strip optional surrounding quotes from value.
    -            let v = v.trim_matches('"').trim_matches('\'');
    -            attrs.insert(k.to_owned(), v.to_owned());
    -        } else if language.is_none() {
    -            language = Some(tok.to_owned());
    -        }
    -    }
    -    (language, attrs)
    -}
    -
    -fn is_word_boundary(bytes: &[u8], i: usize) -> bool {
    -    if i == 0 {
    -        return true;
    -    }
    -    let prev = bytes[i - 1];
    -    !(prev.is_ascii_alphanumeric() || prev == b'_')
    -}
    -
    -fn is_word_end(bytes: &[u8], i: usize) -> bool {
    -    if i >= bytes.len() {
    -        return true;
    -    }
    -    let next = bytes[i];
    -    !(next.is_ascii_alphanumeric() || next == b'_')
    -}
    -
    -const URL_SCHEMES: &[&str] = &["https://", "http://", "ftp://", "mailto:", "file://"];
    -
    -fn match_url(rest: &str) -> Option {
    -    let bytes = rest.as_bytes();
    -    let scheme_len = URL_SCHEMES.iter().find_map(|s| {
    -        if rest.starts_with(s) {
    -            Some(s.len())
    -        } else {
    -            None
    -        }
    -    })?;
    -    let mut end = scheme_len;
    -    while end < bytes.len() {
    -        let b = bytes[end];
    -        // Conservative URL char set; stops at whitespace and most punctuation
    -        // that's typically not part of a URL tail.
    -        let ok = b.is_ascii_alphanumeric()
    -            || matches!(
    -                b,
    -                b'-' | b'_'
    -                    | b'.'
    -                    | b'~'
    -                    | b'/'
    -                    | b':'
    -                    | b'?'
    -                    | b'#'
    -                    | b'['
    -                    | b']'
    -                    | b'@'
    -                    | b'!'
    -                    | b'$'
    -                    | b'&'
    -                    | b'\''
    -                    | b'('
    -                    | b')'
    -                    | b'*'
    -                    | b'+'
    -                    | b','
    -                    | b';'
    -                    | b'='
    -                    | b'%'
    -            );
    -        if !ok {
    -            break;
    -        }
    -        end += 1;
    -    }
    -    // Trim trailing punctuation that's almost always sentence punctuation.
    -    while end > scheme_len {
    -        match bytes[end - 1] {
    -            b'.' | b',' | b';' | b':' | b'!' | b'?' | b')' | b']' => end -= 1,
    -            _ => break,
    -        }
    -    }
    -    if end <= scheme_len {
    -        None
    -    } else {
    -        Some(end)
    -    }
    -}
    -
    -const KEYWORDS: &[(&str, Keyword)] = &[
    -    ("TODO", Keyword::Todo),
    -    ("DONE", Keyword::Done),
    -    ("STARTED", Keyword::Started),
    -    ("FIXME", Keyword::Fixme),
    -    ("FIXED", Keyword::Fixed),
    -    ("XXX", Keyword::Xxx),
    -    ("STOPPED", Keyword::Stopped),
    -];
    -
    -fn match_keyword(rest: &str) -> Option<(usize, Keyword)> {
    -    let bytes = rest.as_bytes();
    -    KEYWORDS.iter().find_map(|(text, kw)| {
    -        if rest.starts_with(text) && is_word_end(bytes, text.len()) {
    -            Some((text.len(), *kw))
    -        } else {
    -            None
    -        }
    -    })
    -}
    -
    -fn list_marker_at(s: &str) -> Option<(ListSymbol, usize)> {
    -    let bytes = s.as_bytes();
    -    if bytes.is_empty() {
    -        return None;
    -    }
    -    // Single-char markers: -, *, #
    -    match bytes[0] {
    -        b'-' => return Some((ListSymbol::Dash, 1)),
    -        b'*' => return Some((ListSymbol::Star, 1)),
    -        b'#' => return Some((ListSymbol::Hash, 1)),
    -        _ => {}
    -    }
    -    // Numeric: `1.` or `1)`. Allow multi-digit.
    -    let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
    -    if digits > 0 && bytes.len() > digits {
    -        match bytes[digits] {
    -            b'.' => return Some((ListSymbol::Numeric, digits + 1)),
    -            b')' => return Some((ListSymbol::NumericParen, digits + 1)),
    -            _ => {}
    -        }
    -    }
    -    // Single-letter alpha or roman: `a)`, `A)`, `i)`, `I)`.
    -    if bytes.len() >= 2 && bytes[1] == b')' {
    -        let c = bytes[0];
    -        if c.is_ascii_lowercase() {
    -            // `i)` / `v)` / `x)` are roman by convention; without context we
    -            // treat any single lowercase letter as alpha. Roman vs alpha is
    -            // ambiguous from a single char; vimwiki itself relies on context.
    -            // Mark `i` as RomanParen as a small concession to the spec.
    -            let sym = if c == b'i' {
    -                ListSymbol::RomanParen
    -            } else {
    -                ListSymbol::AlphaParen
    -            };
    -            return Some((sym, 2));
    -        }
    -        if c.is_ascii_uppercase() {
    -            let sym = if c == b'I' {
    -                ListSymbol::RomanUpperParen
    -            } else {
    -                ListSymbol::AlphaUpperParen
    -            };
    -            return Some((sym, 2));
    -        }
    -    }
    -    None
    -}
    -
    -fn looks_like_list_marker(s: &str) -> bool {
    -    list_marker_at(s).is_some_and(|(_, len)| s.as_bytes().get(len) == Some(&b' '))
    -}
    -
    -/// Is this cell content a valid separator-cell body? Allows leading /
    -/// trailing `:` for alignment and inner runs of `-` plus whitespace.
    -fn is_sep_cell(cell: &str) -> bool {
    -    let t = cell.trim();
    -    if t.is_empty() {
    -        return false;
    -    }
    -    let body = t.trim_start_matches(':').trim_end_matches(':');
    -    !body.is_empty() && body.chars().all(|c| c == '-')
    -}
    -
    -fn parse_sep_alignment(cell: &str) -> crate::ast::TableAlign {
    -    let t = cell.trim();
    -    let l = t.starts_with(':');
    -    let r = t.ends_with(':');
    -    match (l, r) {
    -        (true, true) => crate::ast::TableAlign::Center,
    -        (true, false) => crate::ast::TableAlign::Left,
    -        (false, true) => crate::ast::TableAlign::Right,
    -        _ => crate::ast::TableAlign::Default,
    -    }
    -}
    -
    -/// Recognise a `[g]` checkbox at the start of `s`, where `g` is a single
    -/// glyph drawn from `listsyms` (or the rejected marker). Returns the bucketed
    -/// state and the byte length consumed (including the brackets).
    -fn checkbox_at(s: &str, listsyms: &ListSyms) -> Option<(CheckboxState, usize)> {
    -    let rest = s.strip_prefix('[')?;
    -    let mut chars = rest.char_indices();
    -    let (_, glyph) = chars.next()?;
    -    let (close_off, ']') = chars.next()? else {
    -        return None;
    -    };
    -    let state = listsyms.state_of(glyph)?;
    -    // `[` + glyph + `]`: 1 + glyph.len_utf8() + 1, i.e. the close bracket
    -    // offset within `rest` plus one for the opening bracket and one for `]`.
    -    Some((state, 1 + close_off + 1))
    -}
    diff --git a/crates/nuwiki-core/src/syntax/vimwiki/mod.rs b/crates/nuwiki-core/src/syntax/vimwiki/mod.rs
    deleted file mode 100644
    index 9f358b3..0000000
    --- a/crates/nuwiki-core/src/syntax/vimwiki/mod.rs
    +++ /dev/null
    @@ -1,50 +0,0 @@
    -//! Vimwiki syntax plugin.
    -
    -pub mod lexer;
    -pub mod parser;
    -
    -pub use lexer::{VimwikiLexer, VimwikiToken, VimwikiTokenKind};
    -pub use parser::VimwikiParser;
    -
    -use crate::ast::DocumentNode;
    -use crate::listsyms::ListSyms;
    -use crate::syntax::{Lexer, Parser, SyntaxPlugin};
    -
    -/// SyntaxPlugin facade: chains `VimwikiLexer` + `VimwikiParser` so the
    -/// registry can hand out a single trait object.
    -#[derive(Debug, Default, Clone)]
    -pub struct VimwikiSyntax;
    -
    -impl VimwikiSyntax {
    -    pub fn new() -> Self {
    -        Self
    -    }
    -
    -    /// Parse with a custom checkbox palette (vimwiki's `g:vimwiki_listsyms`).
    -    /// The trait-level [`SyntaxPlugin::parse`] uses [`ListSyms::default`].
    -    pub fn parse_with_listsyms(&self, text: &str, listsyms: &ListSyms) -> DocumentNode {
    -        let tokens = VimwikiLexer::new()
    -            .with_listsyms(listsyms.clone())
    -            .lex(text);
    -        VimwikiParser::new().parse(tokens)
    -    }
    -}
    -
    -impl SyntaxPlugin for VimwikiSyntax {
    -    fn id(&self) -> &str {
    -        "vimwiki"
    -    }
    -
    -    fn display_name(&self) -> &str {
    -        "Vimwiki"
    -    }
    -
    -    fn file_extensions(&self) -> &[&str] {
    -        &[".wiki"]
    -    }
    -
    -    fn parse(&self, text: &str) -> DocumentNode {
    -        let tokens = VimwikiLexer::new().lex(text);
    -        VimwikiParser::new().parse(tokens)
    -    }
    -}
    diff --git a/crates/nuwiki-core/src/syntax/vimwiki/parser.rs b/crates/nuwiki-core/src/syntax/vimwiki/parser.rs
    deleted file mode 100644
    index 89f2ea1..0000000
    --- a/crates/nuwiki-core/src/syntax/vimwiki/parser.rs
    +++ /dev/null
    @@ -1,1436 +0,0 @@
    -//! Vimwiki parser.
    -//!
    -//! Hand-rolled recursive-descent over `Vec`.
    -//! Resilient: never aborts the document. Anything the dispatcher can't
    -//! claim becomes a `BlockNode::Error(ErrorNode)` so progress is
    -//! guaranteed.
    -//!
    -//! ## Inline marker precedence
    -//!
    -//! Within a text run, the lexer emits delimiter tokens
    -//! (`BoldDelim` / `ItalicDelim` / `StrikethroughDelim` /
    -//! `SuperscriptDelim` / `SubscriptDelim`) one at a time. The parser pairs
    -//! them by scanning forward for the *next* token of the same kind, then
    -//! recursing on the slice between them:
    -//!
    -//!   - `*foo*`     → Bold
    -//!   - `_foo_`     → Italic
    -//!   - `~~foo~~`   → Strikethrough
    -//!   - `^foo^`     → Superscript
    -//!   - `,,foo,,`   → Subscript
    -//!   - `_*foo*_`   → Italic(Bold(...))   — semantically bold-italic
    -//!   - `*_foo_*`   → Bold(Italic(...))   — semantically bold-italic
    -//!
    -//! Unmatched delimiters fall back to literal `Text` nodes so input like
    -//! `2 * 3 = 6` survives without a closing pair.
    -//!
    -//! ## Link target classification
    -//!
    -//! `[[ ]]` payloads are inspected to choose between `WikiLinkNode`
    -//! (the default) and `ExternalLinkNode` (when the target is a URL). The
    -//! `LinkTarget` enum follows the vimwiki conventions: leading `/` is root-relative,
    -//! `//` is filesystem-absolute, `wiki:` / `wn.:` are interwiki,
    -//! `diary:` / `file:` / `local:` are their own kinds, and a trailing `/`
    -//! marks a directory link.
    -
    -use crate::ast::{
    -    BlockNode, BlockquoteNode, BoldNode, CodeNode, CommentNode, DefinitionItemNode,
    -    DefinitionListNode, DocumentNode, ErrorNode, ExternalLinkNode, HeadingNode, HorizontalRuleNode,
    -    InlineNode, ItalicNode, KeywordNode, LinkKind, LinkTarget, ListItemNode, ListNode, ListSymbol,
    -    MathBlockNode, MathInlineNode, PageMetadata, ParagraphNode, PreformattedNode, RawUrlNode,
    -    SoftBreakNode, Span, StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode,
    -    TableNode, TableRowNode, TagNode, TagScope, TextNode, TransclusionNode, WikiLinkNode,
    -};
    -use crate::syntax::{Parser, TokenStream};
    -
    -use super::lexer::{VimwikiToken, VimwikiTokenKind as K};
    -
    -#[derive(Debug, Default, Clone)]
    -pub struct VimwikiParser;
    -
    -impl VimwikiParser {
    -    pub fn new() -> Self {
    -        Self
    -    }
    -}
    -
    -impl Parser for VimwikiParser {
    -    type Token = VimwikiToken;
    -
    -    fn parse(&self, tokens: TokenStream) -> DocumentNode {
    -        let v = tokens.into_vec();
    -        let mut state = ParseState::new(&v);
    -        state.parse_document()
    -    }
    -}
    -
    -// ===== Cursor =====
    -
    -struct ParseState<'a> {
    -    toks: &'a [VimwikiToken],
    -    pos: usize,
    -    /// Count of `HeadingNode`s already pushed to `children`.
    -    /// `headings_seen - 1` is the most recent heading's index.
    -    headings_seen: usize,
    -    /// Source line of the most recently-emitted heading.
    -    /// `tag_line - last_heading_line ∈ {1, 2}` is a header-scope tag.
    -    last_heading_line: Option,
    -}
    -
    -impl<'a> ParseState<'a> {
    -    fn new(toks: &'a [VimwikiToken]) -> Self {
    -        Self {
    -            toks,
    -            pos: 0,
    -            headings_seen: 0,
    -            last_heading_line: None,
    -        }
    -    }
    -
    -    fn at_eof(&self) -> bool {
    -        self.pos >= self.toks.len()
    -    }
    -
    -    fn peek(&self) -> Option<&'a VimwikiToken> {
    -        self.toks.get(self.pos)
    -    }
    -
    -    fn peek_kind(&self) -> Option<&'a K> {
    -        self.peek().map(|t| &t.kind)
    -    }
    -
    -    fn advance(&mut self) -> Option<&'a VimwikiToken> {
    -        let t = self.toks.get(self.pos);
    -        if t.is_some() {
    -            self.pos += 1;
    -        }
    -        t
    -    }
    -
    -    fn skip_blanks(&mut self) {
    -        while matches!(self.peek_kind(), Some(K::Newline) | Some(K::BlankLine)) {
    -            self.pos += 1;
    -        }
    -    }
    -
    -    fn eat_newline(&mut self) -> bool {
    -        if matches!(self.peek_kind(), Some(K::Newline)) {
    -            self.pos += 1;
    -            true
    -        } else {
    -            false
    -        }
    -    }
    -
    -    // ===== Document =====
    -
    -    fn parse_document(&mut self) -> DocumentNode {
    -        let start_pos = self.toks.first().map(|t| t.span.start).unwrap_or_default();
    -        let end_pos = self.toks.last().map(|t| t.span.end).unwrap_or(start_pos);
    -
    -        let mut metadata = PageMetadata::default();
    -        let mut children = Vec::new();
    -
    -        loop {
    -            self.skip_blanks();
    -            if self.at_eof() {
    -                break;
    -            }
    -            if self.consume_placeholder(&mut metadata) {
    -                continue;
    -            }
    -            let before = self.pos;
    -            let block = self.parse_block();
    -            // File-scope tags get accumulated into metadata so
    -            // callers can read page-level tag membership without walking
    -            // the AST. The TagNode itself stays in `children` so renderers
    -            // see it too.
    -            if let BlockNode::Tag(t) = &block {
    -                if t.scope == TagScope::File {
    -                    for name in &t.tags {
    -                        if !metadata.tags.iter().any(|t| t == name) {
    -                            metadata.tags.push(name.clone());
    -                        }
    -                    }
    -                }
    -            }
    -            children.push(block);
    -            // Resilience: guarantee forward progress.
    -            if self.pos == before {
    -                let span = self.toks.get(self.pos).map(|t| t.span).unwrap_or_default();
    -                children.push(BlockNode::Error(ErrorNode {
    -                    span,
    -                    raw: format!("{:?}", self.toks.get(self.pos).map(|t| &t.kind)),
    -                    message: "parser made no progress".to_owned(),
    -                }));
    -                self.pos += 1;
    -            }
    -        }
    -
    -        DocumentNode {
    -            span: Span::new(start_pos, end_pos),
    -            children,
    -            metadata,
    -        }
    -    }
    -
    -    fn consume_placeholder(&mut self, metadata: &mut PageMetadata) -> bool {
    -        let updated = match self.peek_kind() {
    -            Some(K::PlaceholderTitle(v)) => {
    -                metadata.title = v.clone();
    -                true
    -            }
    -            Some(K::PlaceholderNohtml) => {
    -                metadata.nohtml = true;
    -                true
    -            }
    -            Some(K::PlaceholderTemplate(v)) => {
    -                metadata.template = v.clone();
    -                true
    -            }
    -            Some(K::PlaceholderDate(v)) => {
    -                metadata.date = v.clone();
    -                true
    -            }
    -            _ => false,
    -        };
    -        if updated {
    -            self.advance();
    -            self.eat_newline();
    -        }
    -        updated
    -    }
    -
    -    // ===== Block dispatch =====
    -
    -    fn parse_block(&mut self) -> BlockNode {
    -        let kind = self.peek_kind().expect("parse_block called at EOF");
    -        match kind {
    -            K::HeadingOpen { .. } => self.parse_heading(),
    -            K::HorizontalRule => self.parse_horizontal_rule(),
    -            K::ListMarker { .. } => self.parse_list(),
    -            K::BlockquoteMarker | K::BlockquoteIndent => self.parse_blockquote(),
    -            K::TableSep | K::TableHeaderRow(_) => self.parse_table(),
    -            K::PreformattedOpen { .. } => self.parse_preformatted(),
    -            K::MathBlockOpen { .. } => self.parse_math_block(),
    -            K::CommentLine(_) => self.parse_single_comment(),
    -            K::CommentMultiOpen => self.parse_multi_comment(),
    -            K::Tag(_) => self.parse_tag(),
    -            _ => {
    -                if self.is_definition_start() {
    -                    self.parse_definition_list()
    -                } else {
    -                    self.parse_paragraph()
    -                }
    -            }
    -        }
    -    }
    -
    -    // ===== Tag =====
    -
    -    fn parse_tag(&mut self) -> BlockNode {
    -        let t = self.advance().unwrap();
    -        let names = match &t.kind {
    -            K::Tag(v) => v.clone(),
    -            _ => unreachable!(),
    -        };
    -        let span = t.span;
    -        let scope = self.scope_for_tag(span.start.line);
    -        self.eat_newline();
    -        BlockNode::Tag(TagNode {
    -            span,
    -            tags: names,
    -            scope,
    -        })
    -    }
    -
    -    /// Decide the placement-determined scope of a tag at the given source
    -    /// line, matching vimwiki's rules:
    -    ///   - lines 0 or 1 → `File`
    -    ///   - within two lines after the most recent heading → `Heading(idx)`
    -    ///   - otherwise → `Standalone`
    -    fn scope_for_tag(&self, tag_line: u32) -> TagScope {
    -        if tag_line <= 1 {
    -            return TagScope::File;
    -        }
    -        if let Some(h_line) = self.last_heading_line {
    -            let delta = tag_line.saturating_sub(h_line);
    -            if (1..=2).contains(&delta) && self.headings_seen > 0 {
    -                return TagScope::Heading(self.headings_seen - 1);
    -            }
    -        }
    -        TagScope::Standalone
    -    }
    -
    -    // ===== Heading =====
    -
    -    fn parse_heading(&mut self) -> BlockNode {
    -        let open = self.advance().unwrap();
    -        let span_start = open.span.start;
    -        let (level, centered) = match &open.kind {
    -            K::HeadingOpen { level, centered } => (*level, *centered),
    -            _ => unreachable!(),
    -        };
    -        let inline_start = self.pos;
    -        let mut span_end = open.span.end;
    -        while let Some(t) = self.peek() {
    -            match &t.kind {
    -                K::HeadingClose => {
    -                    span_end = t.span.end;
    -                    let inline = parse_inline_seq(&self.toks[inline_start..self.pos]);
    -                    self.advance();
    -                    self.eat_newline();
    -                    // Record for tag-scope resolution.
    -                    self.headings_seen += 1;
    -                    self.last_heading_line = Some(span_end.line);
    -                    return BlockNode::Heading(HeadingNode {
    -                        span: Span::new(span_start, span_end),
    -                        level,
    -                        centered,
    -                        children: inline,
    -                    });
    -                }
    -                K::Newline | K::BlankLine => break,
    -                _ => {
    -                    self.advance();
    -                }
    -            }
    -        }
    -        // No closing `=` seen: emit a paragraph from what we collected.
    -        let inline = parse_inline_seq(&self.toks[inline_start..self.pos]);
    -        self.eat_newline();
    -        BlockNode::Paragraph(ParagraphNode {
    -            span: Span::new(span_start, span_end),
    -            children: inline,
    -        })
    -    }
    -
    -    // ===== Horizontal rule =====
    -
    -    fn parse_horizontal_rule(&mut self) -> BlockNode {
    -        let t = self.advance().unwrap();
    -        let span = t.span;
    -        self.eat_newline();
    -        BlockNode::HorizontalRule(HorizontalRuleNode { span })
    -    }
    -
    -    // ===== Comments =====
    -
    -    fn parse_single_comment(&mut self) -> BlockNode {
    -        let t = self.advance().unwrap();
    -        let content = match &t.kind {
    -            K::CommentLine(s) => s.clone(),
    -            _ => unreachable!(),
    -        };
    -        let span = t.span;
    -        self.eat_newline();
    -        BlockNode::Comment(CommentNode { span, content })
    -    }
    -
    -    fn parse_multi_comment(&mut self) -> BlockNode {
    -        let open = self.advance().unwrap();
    -        let span_start = open.span.start;
    -        let mut span_end = open.span.end;
    -        let mut content = String::new();
    -        let mut first = true;
    -        while let Some(t) = self.peek() {
    -            match &t.kind {
    -                K::CommentMultiClose => {
    -                    span_end = t.span.end;
    -                    self.advance();
    -                    self.eat_newline();
    -                    return BlockNode::Comment(CommentNode {
    -                        span: Span::new(span_start, span_end),
    -                        content,
    -                    });
    -                }
    -                K::CommentMultiLine(s) => {
    -                    if !first {
    -                        content.push('\n');
    -                    }
    -                    content.push_str(s);
    -                    first = false;
    -                    span_end = t.span.end;
    -                    self.advance();
    -                }
    -                K::Newline => {
    -                    self.advance();
    -                }
    -                _ => {
    -                    self.advance();
    -                }
    -            }
    -        }
    -        // Unterminated: keep what we have.
    -        BlockNode::Comment(CommentNode {
    -            span: Span::new(span_start, span_end),
    -            content,
    -        })
    -    }
    -
    -    // ===== Preformatted =====
    -
    -    fn parse_preformatted(&mut self) -> BlockNode {
    -        let open = self.advance().unwrap();
    -        let span_start = open.span.start;
    -        let mut span_end = open.span.end;
    -        let (language, attrs) = match &open.kind {
    -            K::PreformattedOpen { language, raw, .. } => (language.clone(), raw.clone()),
    -            _ => unreachable!(),
    -        };
    -        let mut content = String::new();
    -        let mut first = true;
    -        while let Some(t) = self.peek() {
    -            match &t.kind {
    -                K::PreformattedClose => {
    -                    span_end = t.span.end;
    -                    self.advance();
    -                    self.eat_newline();
    -                    return BlockNode::Preformatted(PreformattedNode {
    -                        span: Span::new(span_start, span_end),
    -                        content,
    -                        language,
    -                        attrs,
    -                    });
    -                }
    -                K::PreformattedLine(s) => {
    -                    if !first {
    -                        content.push('\n');
    -                    }
    -                    content.push_str(s);
    -                    first = false;
    -                    span_end = t.span.end;
    -                    self.advance();
    -                }
    -                K::Newline => {
    -                    self.advance();
    -                }
    -                _ => {
    -                    self.advance();
    -                }
    -            }
    -        }
    -        BlockNode::Preformatted(PreformattedNode {
    -            span: Span::new(span_start, span_end),
    -            content,
    -            language,
    -            attrs,
    -        })
    -    }
    -
    -    // ===== Math block =====
    -
    -    fn parse_math_block(&mut self) -> BlockNode {
    -        let open = self.advance().unwrap();
    -        let span_start = open.span.start;
    -        let mut span_end = open.span.end;
    -        let environment = match &open.kind {
    -            K::MathBlockOpen { environment } => environment.clone(),
    -            _ => unreachable!(),
    -        };
    -        let mut content = String::new();
    -        let mut first = true;
    -        while let Some(t) = self.peek() {
    -            match &t.kind {
    -                K::MathBlockClose => {
    -                    span_end = t.span.end;
    -                    self.advance();
    -                    self.eat_newline();
    -                    return BlockNode::MathBlock(MathBlockNode {
    -                        span: Span::new(span_start, span_end),
    -                        content,
    -                        environment,
    -                    });
    -                }
    -                K::MathBlockLine(s) => {
    -                    if !first {
    -                        content.push('\n');
    -                    }
    -                    content.push_str(s);
    -                    first = false;
    -                    span_end = t.span.end;
    -                    self.advance();
    -                }
    -                K::Newline => {
    -                    self.advance();
    -                }
    -                _ => {
    -                    self.advance();
    -                }
    -            }
    -        }
    -        BlockNode::MathBlock(MathBlockNode {
    -            span: Span::new(span_start, span_end),
    -            content,
    -            environment,
    -        })
    -    }
    -
    -    // ===== List =====
    -
    -    fn parse_list(&mut self) -> BlockNode {
    -        let first = self.peek().expect("parse_list at EOF");
    -        let span_start = first.span.start;
    -        let (base_symbol, base_indent) = match &first.kind {
    -            K::ListMarker { symbol, indent } => (*symbol, *indent),
    -            _ => unreachable!(),
    -        };
    -        let mut items = Vec::new();
    -        let mut span_end = span_start;
    -        while let Some(t) = self.peek() {
    -            match &t.kind {
    -                K::ListMarker { indent, .. } if *indent == base_indent => {
    -                    let item = self.parse_list_item();
    -                    span_end = item.span.end;
    -                    items.push(item);
    -                }
    -                _ => break,
    -            }
    -        }
    -        BlockNode::List(ListNode {
    -            span: Span::new(span_start, span_end),
    -            ordered: matches!(
    -                base_symbol,
    -                ListSymbol::Numeric
    -                    | ListSymbol::NumericParen
    -                    | ListSymbol::AlphaParen
    -                    | ListSymbol::AlphaUpperParen
    -                    | ListSymbol::RomanParen
    -                    | ListSymbol::RomanUpperParen
    -            ),
    -            symbol: base_symbol,
    -            items,
    -        })
    -    }
    -
    -    fn parse_list_item(&mut self) -> ListItemNode {
    -        let marker = self.advance().expect("list item without marker");
    -        let span_start = marker.span.start;
    -        let mut span_end = marker.span.end;
    -        let (symbol, level) = match &marker.kind {
    -            K::ListMarker { symbol, indent } => (*symbol, *indent as usize),
    -            _ => unreachable!(),
    -        };
    -
    -        // Optional Checkbox
    -        let checkbox = if let Some(K::Checkbox(state)) = self.peek_kind() {
    -            let state = *state;
    -            let t = self.advance().unwrap();
    -            span_end = t.span.end;
    -            Some(state)
    -        } else {
    -            None
    -        };
    -
    -        // Inline content until end of line.
    -        let inline_start = self.pos;
    -        while let Some(t) = self.peek() {
    -            match &t.kind {
    -                K::Newline | K::BlankLine => break,
    -                _ => {
    -                    span_end = t.span.end;
    -                    self.advance();
    -                }
    -            }
    -        }
    -        let inline_end = self.pos;
    -        let mut children = parse_inline_seq(&self.toks[inline_start..inline_end]);
    -        self.eat_newline();
    -
    -        // Continuation lines (vimwiki parity): lines indented strictly past
    -        // the marker's column attach to this item rather than becoming a
    -        // sibling paragraph. The lexer doesn't model list state, so we
    -        // detect this here by inspecting whatever the next line opens
    -        // with: either a `Text(s)` whose leading whitespace exceeds the
    -        // marker's column, or a `BlockquoteIndent` token (the lexer emits
    -        // it for 4+ leading spaces, swallowing them from the following
    -        // Text). We accept either as a continuation when the implied
    -        // indent is `> level`.
    -        let continuation_threshold = level + 1;
    -        loop {
    -            let Some(first) = self.peek() else { break };
    -            let mut consume_first = false;
    -            let is_continuation = match &first.kind {
    -                K::Text(s) => leading_ws_count(s) >= continuation_threshold,
    -                K::BlockquoteIndent => {
    -                    // `BlockquoteIndent`'s span covers the leading-whitespace
    -                    // run on the current line. Width ≥ threshold means this
    -                    // is a continuation, not a blockquote.
    -                    let width = (first.span.end.column - first.span.start.column) as usize;
    -                    if width >= continuation_threshold {
    -                        consume_first = true;
    -                        true
    -                    } else {
    -                        false
    -                    }
    -                }
    -                _ => false,
    -            };
    -            if !is_continuation {
    -                break;
    -            }
    -            // Eat the synthetic BlockquoteIndent if present — the lexer
    -            // already stripped the whitespace from the following Text so
    -            // we don't want to also emit a leading space ourselves.
    -            if consume_first {
    -                self.advance();
    -            }
    -            let cont_start = self.pos;
    -            while let Some(t) = self.peek() {
    -                match &t.kind {
    -                    K::Newline | K::BlankLine => break,
    -                    _ => {
    -                        span_end = t.span.end;
    -                        self.advance();
    -                    }
    -                }
    -            }
    -            let cont_end = self.pos;
    -            // Drop the leading whitespace on the first text token (only
    -            // present when we came via the Text branch — the
    -            // BlockquoteIndent path already stripped it).
    -            let mut buf: Vec = self.toks[cont_start..cont_end].to_vec();
    -            if !consume_first {
    -                if let Some(t0) = buf.first_mut() {
    -                    if let K::Text(text) = &t0.kind {
    -                        let trimmed = text.trim_start().to_owned();
    -                        t0.kind = K::Text(trimmed);
    -                    }
    -                }
    -            }
    -            // Insert a soft break between the previous content and the
    -            // continuation so the rendered text flows naturally (and honours
    -            // `*_ignore_newline`).
    -            if !children.is_empty() {
    -                children.push(InlineNode::SoftBreak(SoftBreakNode { span: first.span }));
    -            }
    -            let cont = parse_inline_seq(&buf);
    -            children.extend(cont);
    -            if !self.eat_newline() {
    -                break;
    -            }
    -        }
    -
    -        // Sublist: subsequent ListMarker tokens with deeper indent.
    -        let sublist = if let Some(K::ListMarker { indent, .. }) = self.peek_kind() {
    -            if (*indent as usize) > level {
    -                let block = self.parse_list();
    -                if let BlockNode::List(node) = block {
    -                    span_end = node.span.end;
    -                    Some(node)
    -                } else {
    -                    None
    -                }
    -            } else {
    -                None
    -            }
    -        } else {
    -            None
    -        };
    -
    -        ListItemNode {
    -            span: Span::new(span_start, span_end),
    -            symbol,
    -            level,
    -            checkbox,
    -            children,
    -            sublist,
    -        }
    -    }
    -
    -    // ===== Blockquote =====
    -
    -    fn parse_blockquote(&mut self) -> BlockNode {
    -        let first = self.peek().unwrap();
    -        let span_start = first.span.start;
    -        let mut span_end = span_start;
    -        let mut lines: Vec> = Vec::new();
    -        while let Some(t) = self.peek() {
    -            match &t.kind {
    -                K::BlockquoteMarker | K::BlockquoteIndent => {
    -                    self.advance();
    -                    let inline_start = self.pos;
    -                    while let Some(t2) = self.peek() {
    -                        match &t2.kind {
    -                            K::Newline | K::BlankLine => break,
    -                            _ => {
    -                                span_end = t2.span.end;
    -                                self.advance();
    -                            }
    -                        }
    -                    }
    -                    let inline_end = self.pos;
    -                    let inline = parse_inline_seq(&self.toks[inline_start..inline_end]);
    -                    if !inline.is_empty() {
    -                        lines.push(inline);
    -                    }
    -                    self.eat_newline();
    -                }
    -                _ => break,
    -            }
    -        }
    -        let mut children: Vec = Vec::with_capacity(lines.len());
    -        for line in lines {
    -            let line_start = line
    -                .first()
    -                .and_then(span_start_of_inline)
    -                .unwrap_or(span_start);
    -            let line_end = line.last().and_then(span_end_of_inline).unwrap_or(span_end);
    -            children.push(BlockNode::Paragraph(ParagraphNode {
    -                span: Span::new(line_start, line_end),
    -                children: line,
    -            }));
    -        }
    -        BlockNode::Blockquote(BlockquoteNode {
    -            span: Span::new(span_start, span_end),
    -            children,
    -        })
    -    }
    -
    -    // ===== Table =====
    -
    -    fn parse_table(&mut self) -> BlockNode {
    -        let first = self.peek().unwrap();
    -        let span_start = first.span.start;
    -        let mut span_end = span_start;
    -        let mut rows: Vec = Vec::new();
    -        let mut next_is_header = false;
    -        let mut has_header = false;
    -        let mut alignments: Vec = Vec::new();
    -
    -        while let Some(t) = self.peek() {
    -            match &t.kind {
    -                K::TableHeaderRow(aligns) => {
    -                    has_header = true;
    -                    if alignments.is_empty() {
    -                        alignments = aligns.clone();
    -                    }
    -                    if let Some(last) = rows.last_mut() {
    -                        last.is_header = true;
    -                    }
    -                    next_is_header = true;
    -                    span_end = t.span.end;
    -                    self.advance();
    -                    self.eat_newline();
    -                }
    -                K::TableSep => {
    -                    let row = self.parse_table_row();
    -                    span_end = row.span.end;
    -                    rows.push(row);
    -                    if next_is_header {
    -                        next_is_header = false;
    -                    }
    -                }
    -                _ => break,
    -            }
    -        }
    -        BlockNode::Table(TableNode {
    -            span: Span::new(span_start, span_end),
    -            rows,
    -            has_header,
    -            alignments,
    -        })
    -    }
    -
    -    fn parse_table_row(&mut self) -> TableRowNode {
    -        // Consume leading TableSep
    -        let first_sep = self.advance().expect("table row without leading |");
    -        let span_start = first_sep.span.start;
    -        let mut span_end = first_sep.span.end;
    -        let mut cells: Vec = Vec::new();
    -
    -        while let Some(t) = self.peek() {
    -            match &t.kind {
    -                K::Newline | K::BlankLine => break,
    -                K::TableSep => {
    -                    span_end = t.span.end;
    -                    self.advance();
    -                }
    -                K::TableColSpan => {
    -                    let span = t.span;
    -                    span_end = span.end;
    -                    self.advance();
    -                    cells.push(TableCellNode {
    -                        span,
    -                        children: Vec::new(),
    -                        col_span: true,
    -                        row_span: false,
    -                    });
    -                }
    -                K::TableRowSpan => {
    -                    let span = t.span;
    -                    span_end = span.end;
    -                    self.advance();
    -                    cells.push(TableCellNode {
    -                        span,
    -                        children: Vec::new(),
    -                        col_span: false,
    -                        row_span: true,
    -                    });
    -                }
    -                _ => {
    -                    let cell_start_idx = self.pos;
    -                    let cell_start_pos = t.span.start;
    -                    let mut cell_end_pos = t.span.end;
    -                    while let Some(tt) = self.peek() {
    -                        match &tt.kind {
    -                            K::TableSep | K::Newline | K::BlankLine => break,
    -                            _ => {
    -                                cell_end_pos = tt.span.end;
    -                                self.advance();
    -                            }
    -                        }
    -                    }
    -                    let inline = parse_inline_seq(&self.toks[cell_start_idx..self.pos]);
    -                    span_end = cell_end_pos;
    -                    cells.push(TableCellNode {
    -                        span: Span::new(cell_start_pos, cell_end_pos),
    -                        children: inline,
    -                        col_span: false,
    -                        row_span: false,
    -                    });
    -                }
    -            }
    -        }
    -        self.eat_newline();
    -        TableRowNode {
    -            span: Span::new(span_start, span_end),
    -            cells,
    -            is_header: false,
    -        }
    -    }
    -
    -    // ===== Definition list =====
    -
    -    fn is_definition_start(&self) -> bool {
    -        // Walk forward on the current line. If we find a DefinitionTermMarker
    -        // before a Newline/BlankLine, treat as a definition.
    -        let mut i = self.pos;
    -        while let Some(t) = self.toks.get(i) {
    -            match &t.kind {
    -                K::Newline | K::BlankLine => return false,
    -                K::DefinitionTermMarker => return true,
    -                _ => i += 1,
    -            }
    -        }
    -        false
    -    }
    -
    -    fn parse_definition_list(&mut self) -> BlockNode {
    -        let first = self.peek().unwrap();
    -        let span_start = first.span.start;
    -        let mut span_end = span_start;
    -        let mut items: Vec = Vec::new();
    -        while self.is_definition_start() {
    -            let item = self.parse_definition_item();
    -            span_end = item.span.end;
    -            items.push(item);
    -        }
    -        BlockNode::DefinitionList(DefinitionListNode {
    -            span: Span::new(span_start, span_end),
    -            items,
    -        })
    -    }
    -
    -    fn parse_definition_item(&mut self) -> DefinitionItemNode {
    -        let item_start_idx = self.pos;
    -        let item_span_start = self.toks[item_start_idx].span.start;
    -
    -        // Term: tokens until DefinitionTermMarker
    -        let term_start = self.pos;
    -        while let Some(t) = self.peek() {
    -            match &t.kind {
    -                K::DefinitionTermMarker | K::Newline | K::BlankLine => break,
    -                _ => {
    -                    self.advance();
    -                }
    -            }
    -        }
    -        let term_end = self.pos;
    -        let term_inline = if term_end > term_start {
    -            let v = parse_inline_seq(&self.toks[term_start..term_end]);
    -            if v.is_empty() {
    -                None
    -            } else {
    -                Some(v)
    -            }
    -        } else {
    -            None
    -        };
    -
    -        let mut span_end = self
    -            .toks
    -            .get(term_end.saturating_sub(1))
    -            .map(|t| t.span.end)
    -            .unwrap_or(item_span_start);
    -
    -        // DefinitionTermMarker
    -        if matches!(self.peek_kind(), Some(K::DefinitionTermMarker)) {
    -            span_end = self.peek().unwrap().span.end;
    -            self.advance();
    -        }
    -
    -        // Definition tokens until end of line
    -        let def_start = self.pos;
    -        while let Some(t) = self.peek() {
    -            match &t.kind {
    -                K::Newline | K::BlankLine => break,
    -                _ => {
    -                    span_end = t.span.end;
    -                    self.advance();
    -                }
    -            }
    -        }
    -        let def_end = self.pos;
    -        let mut definitions = Vec::new();
    -        if def_end > def_start {
    -            let v = parse_inline_seq(&self.toks[def_start..def_end]);
    -            if !v.is_empty() {
    -                definitions.push(v);
    -            }
    -        }
    -        self.eat_newline();
    -
    -        DefinitionItemNode {
    -            span: Span::new(item_span_start, span_end),
    -            term: term_inline,
    -            definitions,
    -        }
    -    }
    -
    -    // ===== Paragraph =====
    -
    -    fn parse_paragraph(&mut self) -> BlockNode {
    -        let span_start = self.peek().unwrap().span.start;
    -        let inline_start = self.pos;
    -        // `content_end` excludes a trailing newline that just terminates
    -        // the paragraph (instead of joining two content lines), so it
    -        // doesn't leak into `parse_inline_seq` as a stray soft-break " ".
    -        let mut content_end = self.pos;
    -        let mut span_end = span_start;
    -
    -        loop {
    -            match self.peek_kind() {
    -                None => break,
    -                Some(K::Newline) => {
    -                    span_end = self.peek().unwrap().span.end;
    -                    self.advance();
    -                    match self.peek_kind() {
    -                        None => break,
    -                        Some(k) if starts_new_block(k) => break,
    -                        // Soft-break: the newline joins two content lines.
    -                        // Pull it into the inline range as a space.
    -                        _ => content_end = self.pos,
    -                    }
    -                }
    -                Some(k) if starts_new_block(k) => break,
    -                Some(_) => {
    -                    span_end = self.peek().unwrap().span.end;
    -                    self.advance();
    -                    content_end = self.pos;
    -                }
    -            }
    -        }
    -
    -        let children = parse_inline_seq(&self.toks[inline_start..content_end]);
    -        BlockNode::Paragraph(ParagraphNode {
    -            span: Span::new(span_start, span_end),
    -            children,
    -        })
    -    }
    -}
    -
    -fn starts_new_block(kind: &K) -> bool {
    -    matches!(
    -        kind,
    -        K::BlankLine
    -            | K::HeadingOpen { .. }
    -            | K::HorizontalRule
    -            | K::ListMarker { .. }
    -            | K::BlockquoteMarker
    -            | K::BlockquoteIndent
    -            | K::TableSep
    -            | K::TableHeaderRow(_)
    -            | K::PreformattedOpen { .. }
    -            | K::MathBlockOpen { .. }
    -            | K::CommentLine(_)
    -            | K::CommentMultiOpen
    -            | K::PlaceholderTitle(_)
    -            | K::PlaceholderNohtml
    -            | K::PlaceholderTemplate(_)
    -            | K::PlaceholderDate(_)
    -            | K::Tag(_)
    -    )
    -}
    -
    -// ===== Inline pairing =====
    -
    -fn parse_inline_seq(toks: &[VimwikiToken]) -> Vec {
    -    let mut out = Vec::new();
    -    let mut i = 0;
    -    while i < toks.len() {
    -        let token = &toks[i];
    -        match &token.kind {
    -            K::Text(s) => {
    -                out.push(InlineNode::Text(TextNode {
    -                    span: token.span,
    -                    content: s.clone(),
    -                }));
    -                i += 1;
    -            }
    -            K::Code(s) => {
    -                out.push(InlineNode::Code(CodeNode {
    -                    span: token.span,
    -                    content: s.clone(),
    -                }));
    -                i += 1;
    -            }
    -            K::MathInline(s) => {
    -                out.push(InlineNode::MathInline(MathInlineNode {
    -                    span: token.span,
    -                    content: s.clone(),
    -                }));
    -                i += 1;
    -            }
    -            K::Keyword(k) => {
    -                out.push(InlineNode::Keyword(KeywordNode {
    -                    span: token.span,
    -                    keyword: *k,
    -                }));
    -                i += 1;
    -            }
    -            K::RawUrl(u) => {
    -                out.push(InlineNode::RawUrl(RawUrlNode {
    -                    span: token.span,
    -                    url: u.clone(),
    -                }));
    -                i += 1;
    -            }
    -            K::Newline => {
    -                // Soft break inside a multi-line block. A dedicated node lets
    -                // the renderer honour `*_ignore_newline` (space vs `
    `); - // other consumers treat it as whitespace. - out.push(InlineNode::SoftBreak(SoftBreakNode { span: token.span })); - i += 1; - } - K::BoldDelim => { - i = pair_or_text(toks, i, "*", &mut out, K::BoldDelim, |span, kids| { - InlineNode::Bold(BoldNode { - span, - children: kids, - }) - }) - } - K::ItalicDelim => { - i = pair_or_text(toks, i, "_", &mut out, K::ItalicDelim, |span, kids| { - InlineNode::Italic(ItalicNode { - span, - children: kids, - }) - }) - } - K::StrikethroughDelim => { - i = pair_or_text( - toks, - i, - "~~", - &mut out, - K::StrikethroughDelim, - |span, kids| { - InlineNode::Strikethrough(StrikethroughNode { - span, - children: kids, - }) - }, - ) - } - K::SuperscriptDelim => { - i = pair_or_text(toks, i, "^", &mut out, K::SuperscriptDelim, |span, kids| { - InlineNode::Superscript(SuperscriptNode { - span, - children: kids, - }) - }) - } - K::SubscriptDelim => { - i = pair_or_text(toks, i, ",,", &mut out, K::SubscriptDelim, |span, kids| { - InlineNode::Subscript(SubscriptNode { - span, - children: kids, - }) - }) - } - K::WikiLinkOpen => { - let (node, next) = parse_wikilink(toks, i); - out.push(node); - i = next; - } - K::TransclusionOpen => { - let (node, next) = parse_transclusion(toks, i); - out.push(node); - i = next; - } - // Tokens that shouldn't appear in inline context (e.g. dangling - // close delimiters, separators outside their owners): emit as - // literal text so nothing is silently dropped. - K::WikiLinkClose => push_literal(token, "]]", &mut out, &mut i), - K::WikiLinkSep => push_literal(token, "|", &mut out, &mut i), - K::TransclusionClose => push_literal(token, "}}", &mut out, &mut i), - K::TransclusionSep => push_literal(token, "|", &mut out, &mut i), - K::HeadingClose => push_literal(token, "=", &mut out, &mut i), - // Other tokens (block markers, etc.) should not appear here; skip. - _ => { - i += 1; - } - } - } - out -} - -fn push_literal(token: &VimwikiToken, lit: &str, out: &mut Vec, i: &mut usize) { - out.push(InlineNode::Text(TextNode { - span: token.span, - content: lit.into(), - })); - *i += 1; -} - -fn pair_or_text( - toks: &[VimwikiToken], - open_idx: usize, - literal: &str, - out: &mut Vec, - target: K, - build: F, -) -> usize -where - F: FnOnce(Span, Vec) -> InlineNode, -{ - if let Some(close_idx) = find_close(toks, open_idx, &target) { - let inner = parse_inline_seq(&toks[open_idx + 1..close_idx]); - let span = Span::new(toks[open_idx].span.start, toks[close_idx].span.end); - out.push(build(span, inner)); - close_idx + 1 - } else { - out.push(InlineNode::Text(TextNode { - span: toks[open_idx].span, - content: literal.into(), - })); - open_idx + 1 - } -} - -fn find_close(toks: &[VimwikiToken], start: usize, target: &K) -> Option { - for (offset, t) in toks.iter().enumerate().skip(start + 1) { - if &t.kind == target { - return Some(offset); - } - } - None -} - -// ===== Wikilink + transclusion ===== - -fn parse_wikilink(toks: &[VimwikiToken], open_idx: usize) -> (InlineNode, usize) { - let span_start = toks[open_idx].span.start; - let mut i = open_idx + 1; - let target_start = i; - let mut target_end = i; - let mut sep_idx: Option = None; - let mut close_idx: Option = None; - while i < toks.len() { - match &toks[i].kind { - K::WikiLinkClose => { - close_idx = Some(i); - break; - } - K::WikiLinkSep if sep_idx.is_none() => { - sep_idx = Some(i); - target_end = i; - } - _ => {} - } - i += 1; - } - let close_idx = match close_idx { - Some(c) => c, - None => { - // Unterminated link: emit literal `[[`. - return ( - InlineNode::Text(TextNode { - span: toks[open_idx].span, - content: "[[".into(), - }), - open_idx + 1, - ); - } - }; - - if sep_idx.is_none() { - target_end = close_idx; - } - - let target_text = collect_text(&toks[target_start..target_end]); - let span = Span::new(span_start, toks[close_idx].span.end); - - let description: Option> = sep_idx.map(|sep| { - let inner = parse_inline_seq(&toks[sep + 1..close_idx]); - if inner.is_empty() { - vec![] - } else { - inner - } - }); - - if is_url(&target_text) { - ( - InlineNode::ExternalLink(ExternalLinkNode { - span, - url: target_text, - description, - }), - close_idx + 1, - ) - } else { - let target = parse_link_target(&target_text); - ( - InlineNode::WikiLink(WikiLinkNode { - span, - target, - description, - }), - close_idx + 1, - ) - } -} - -fn parse_transclusion(toks: &[VimwikiToken], open_idx: usize) -> (InlineNode, usize) { - let span_start = toks[open_idx].span.start; - let mut i = open_idx + 1; - let url_start = i; - let mut url_end = i; - let mut seps: Vec = Vec::new(); - let mut close_idx: Option = None; - while i < toks.len() { - match &toks[i].kind { - K::TransclusionClose => { - close_idx = Some(i); - break; - } - K::TransclusionSep => { - if seps.is_empty() { - url_end = i; - } - seps.push(i); - } - _ => {} - } - i += 1; - } - let close_idx = match close_idx { - Some(c) => c, - None => { - return ( - InlineNode::Text(TextNode { - span: toks[open_idx].span, - content: "{{".into(), - }), - open_idx + 1, - ); - } - }; - if seps.is_empty() { - url_end = close_idx; - } - let url = collect_text(&toks[url_start..url_end]); - let span = Span::new(span_start, toks[close_idx].span.end); - - // After URL: optional alt, then key=val attrs. - let mut alt = None; - let mut attrs = std::collections::HashMap::new(); - if !seps.is_empty() { - let alt_start = seps[0] + 1; - let alt_end = if seps.len() >= 2 { seps[1] } else { close_idx }; - let alt_text = collect_text(&toks[alt_start..alt_end]); - if !alt_text.is_empty() { - alt = Some(alt_text); - } - for window in seps.windows(2) { - let s = window[0]; - let e = window[1]; - let segment = collect_text(&toks[s + 1..e]); - insert_attr(&segment, &mut attrs); - } - // Last attribute segment between final sep and close. - if let Some(&last) = seps.last() { - if last + 1 < close_idx && seps.len() >= 2 { - let segment = collect_text(&toks[last + 1..close_idx]); - insert_attr(&segment, &mut attrs); - } - } - } - - ( - InlineNode::Transclusion(TransclusionNode { - span, - url, - alt, - attrs, - }), - close_idx + 1, - ) -} - -/// Count leading space / tab characters at the start of `s`. Used by -/// `parse_list_item` to detect continuation lines. -fn leading_ws_count(s: &str) -> usize { - s.chars().take_while(|c| *c == ' ' || *c == '\t').count() -} - -/// Split a `key=value` attribute segment, strip surrounding quotes from -/// the value, and insert into `attrs`. Empty keys are skipped. -fn insert_attr(segment: &str, attrs: &mut std::collections::HashMap) { - let Some(eq) = segment.find('=') else { return }; - let k = segment[..eq].trim().to_owned(); - if k.is_empty() { - return; - } - let raw = segment[eq + 1..].trim(); - let v = strip_quotes(raw).to_owned(); - attrs.insert(k, v); -} - -fn strip_quotes(s: &str) -> &str { - let b = s.as_bytes(); - if b.len() >= 2 - && ((b[0] == b'"' && b[b.len() - 1] == b'"') || (b[0] == b'\'' && b[b.len() - 1] == b'\'')) - { - &s[1..s.len() - 1] - } else { - s - } -} - -fn collect_text(toks: &[VimwikiToken]) -> String { - let mut out = String::new(); - for t in toks { - if let K::Text(s) = &t.kind { - out.push_str(s); - } - } - out -} - -fn is_url(s: &str) -> bool { - s.starts_with("http://") - || s.starts_with("https://") - || s.starts_with("ftp://") - || s.starts_with("mailto:") - || s.starts_with("file://") -} - -// ===== LinkTarget classification ===== - -fn parse_link_target(text: &str) -> LinkTarget { - let mut target = LinkTarget { - kind: LinkKind::Wiki, - path: None, - wiki_index: None, - wiki_name: None, - anchor: None, - is_absolute: false, - is_directory: false, - }; - - // Split off anchor first. - let (path_part, anchor) = match text.split_once('#') { - Some((p, a)) => (p, Some(a.to_owned())), - None => (text, None), - }; - target.anchor = anchor; - - if path_part.is_empty() { - target.kind = LinkKind::AnchorOnly; - return target; - } - - // `//path` — filesystem-absolute. - if let Some(rest) = path_part.strip_prefix("//") { - target.kind = LinkKind::Local; - target.is_absolute = true; - let (path, is_dir) = strip_directory(rest); - target.is_directory = is_dir; - target.path = Some(path); - return target; - } - - // `/Page` — root-relative wiki link. - if let Some(rest) = path_part.strip_prefix('/') { - target.kind = LinkKind::Wiki; - target.is_absolute = true; - let (path, is_dir) = strip_directory(rest); - target.is_directory = is_dir; - target.path = Some(path); - return target; - } - - // Scheme prefixes. - if let Some(rest) = path_part.strip_prefix("file:") { - target.kind = LinkKind::File; - target.path = Some(rest.to_owned()); - return target; - } - if let Some(rest) = path_part.strip_prefix("local:") { - target.kind = LinkKind::Local; - target.path = Some(rest.to_owned()); - return target; - } - if let Some(rest) = path_part.strip_prefix("diary:") { - target.kind = LinkKind::Diary; - target.path = Some(rest.to_owned()); - return target; - } - if let Some(rest) = path_part.strip_prefix("wn.") { - if let Some((name, page)) = rest.split_once(':') { - target.kind = LinkKind::Interwiki; - target.wiki_name = Some(name.to_owned()); - target.path = Some(page.to_owned()); - return target; - } - } - if let Some((scheme, rest)) = path_part.split_once(':') { - if let Some(num) = scheme.strip_prefix("wiki") { - if let Ok(idx) = num.parse::() { - target.kind = LinkKind::Interwiki; - target.wiki_index = Some(idx); - target.path = Some(rest.to_owned()); - return target; - } - } - } - - // Default: plain wiki link, possibly directory. - let (path, is_dir) = strip_directory(path_part); - target.kind = LinkKind::Wiki; - target.is_directory = is_dir; - target.path = Some(path); - target -} - -fn strip_directory(s: &str) -> (String, bool) { - if let Some(stripped) = s.strip_suffix('/') { - (stripped.to_owned(), true) - } else { - (s.to_owned(), false) - } -} - -// ===== Span helpers ===== - -fn span_start_of_inline(node: &InlineNode) -> Option { - Some(node.span().start) -} - -fn span_end_of_inline(node: &InlineNode) -> Option { - Some(node.span().end) -} diff --git a/crates/nuwiki-core/tests/diary.rs b/crates/nuwiki-core/tests/diary.rs deleted file mode 100644 index ded92ef..0000000 --- a/crates/nuwiki-core/tests/diary.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! Cluster 4 — diary frequency / period primitives. -//! Tests the date math for ISO weeks, monthly, yearly periods. - -use nuwiki_core::date::{ - DiaryCalendar, DiaryDate, DiaryFrequency, DiaryPeriod, WeekStart, WeeklyStyle, -}; - -#[test] -fn frequency_parses_canonical_strings() { - assert_eq!(DiaryFrequency::parse("daily"), DiaryFrequency::Daily); - assert_eq!(DiaryFrequency::parse("weekly"), DiaryFrequency::Weekly); - assert_eq!(DiaryFrequency::parse("monthly"), DiaryFrequency::Monthly); - assert_eq!(DiaryFrequency::parse("yearly"), DiaryFrequency::Yearly); - assert_eq!(DiaryFrequency::parse("WeEkLy"), DiaryFrequency::Weekly); - // Unknown falls back to Daily (mirrors vimwiki's permissive behaviour). - assert_eq!(DiaryFrequency::parse("hourly"), DiaryFrequency::Daily); - assert_eq!(DiaryFrequency::parse(""), DiaryFrequency::Daily); -} - -#[test] -fn period_format_round_trips() { - let cases = [ - ( - "2026-05-12", - DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()), - ), - ( - "2026-W19", - DiaryPeriod::Week { - iso_year: 2026, - iso_week: 19, - }, - ), - ( - "2026-05", - DiaryPeriod::Month { - year: 2026, - month: 5, - }, - ), - ("2026", DiaryPeriod::Year { year: 2026 }), - ]; - for (s, want) in cases { - let parsed = DiaryPeriod::parse(s).unwrap_or_else(|| panic!("parse {s}")); - assert_eq!(parsed, want, "parse({s})"); - assert_eq!(parsed.format(), s, "format({s})"); - } -} - -#[test] -fn period_parse_rejects_garbage() { - assert!(DiaryPeriod::parse("garbage").is_none()); - assert!(DiaryPeriod::parse("2026-W00").is_none()); - assert!(DiaryPeriod::parse("2026-13").is_none()); // month 13 - assert!(DiaryPeriod::parse("2026-").is_none()); -} - -#[test] -fn iso_week_jan_1_can_belong_to_prior_year() { - // 2021-01-01 was a Friday → ISO week 53 of 2020. - let d = DiaryDate::from_ymd(2021, 1, 1).unwrap(); - let p = DiaryPeriod::week_containing(d); - assert_eq!( - p, - DiaryPeriod::Week { - iso_year: 2020, - iso_week: 53 - } - ); -} - -#[test] -fn iso_week_dec_31_can_belong_to_next_year() { - // 2024-12-30 (Monday) is in ISO week 1 of 2025. - let d = DiaryDate::from_ymd(2024, 12, 30).unwrap(); - let p = DiaryPeriod::week_containing(d); - assert_eq!( - p, - DiaryPeriod::Week { - iso_year: 2025, - iso_week: 1 - } - ); -} - -#[test] -fn iso_week_mid_year_is_consistent() { - // 2026-05-12 is a Tuesday in ISO week 20. - let d = DiaryDate::from_ymd(2026, 5, 12).unwrap(); - let p = DiaryPeriod::week_containing(d); - assert_eq!( - p, - DiaryPeriod::Week { - iso_year: 2026, - iso_week: 20 - } - ); -} - -#[test] -fn week_next_and_prev_step_by_seven_days() { - let p = DiaryPeriod::Week { - iso_year: 2026, - iso_week: 20, - }; - assert_eq!( - p.next(), - DiaryPeriod::Week { - iso_year: 2026, - iso_week: 21 - } - ); - assert_eq!( - p.prev(), - DiaryPeriod::Week { - iso_year: 2026, - iso_week: 19 - } - ); -} - -#[test] -fn week_next_crosses_year_boundary() { - // Week 53 of 2020 → week 53 ends; next should be week 1 of 2021. - let p = DiaryPeriod::Week { - iso_year: 2020, - iso_week: 53, - }; - assert_eq!( - p.next(), - DiaryPeriod::Week { - iso_year: 2021, - iso_week: 1 - } - ); -} - -#[test] -fn month_next_wraps_to_january() { - let dec = DiaryPeriod::Month { - year: 2026, - month: 12, - }; - assert_eq!( - dec.next(), - DiaryPeriod::Month { - year: 2027, - month: 1 - } - ); -} - -#[test] -fn month_prev_wraps_to_december() { - let jan = DiaryPeriod::Month { - year: 2026, - month: 1, - }; - assert_eq!( - jan.prev(), - DiaryPeriod::Month { - year: 2025, - month: 12 - } - ); -} - -#[test] -fn year_next_and_prev_increment_by_one() { - let y = DiaryPeriod::Year { year: 2026 }; - assert_eq!(y.next(), DiaryPeriod::Year { year: 2027 }); - assert_eq!(y.prev(), DiaryPeriod::Year { year: 2025 }); -} - -#[test] -fn day_next_and_prev_still_work() { - let d = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()); - assert_eq!( - d.next(), - DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 13).unwrap()) - ); - assert_eq!( - d.prev(), - DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 11).unwrap()) - ); -} - -// ===== DiaryCalendar — weekly naming styles + week-start ===== -// 2026-05-25 is a Monday (so 2026-05-27 is a Wednesday, 2026-05-24 a Sunday). - -fn day(y: i32, m: u8, d: u8) -> DiaryPeriod { - DiaryPeriod::Day(DiaryDate::from_ymd(y, m, d).unwrap()) -} - -#[test] -fn weekly_style_and_week_start_parse() { - assert_eq!(WeeklyStyle::parse("date"), WeeklyStyle::Date); - assert_eq!(WeeklyStyle::parse("vimwiki"), WeeklyStyle::Date); - assert_eq!(WeeklyStyle::parse("iso"), WeeklyStyle::Iso); - assert_eq!(WeeklyStyle::parse(""), WeeklyStyle::Iso); // default - assert_eq!(WeeklyStyle::parse("WEEK"), WeeklyStyle::Iso); - // WeekStart::parse is exercised via the calendar tests below; unknown - // names fall back to Monday. - assert_eq!(WeekStart::parse("sunday"), WeekStart::parse("SUNDAY")); - assert_eq!(WeekStart::parse("nonsense"), WeekStart::monday()); -} - -#[test] -fn date_mode_weekly_today_is_a_day() { - let cal = DiaryCalendar::new( - DiaryFrequency::Weekly, - WeeklyStyle::Date, - WeekStart::monday(), - ); - assert!(matches!(cal.today(), DiaryPeriod::Day(_))); -} - -#[test] -fn iso_mode_weekly_today_is_a_week() { - let cal = DiaryCalendar::new( - DiaryFrequency::Weekly, - WeeklyStyle::Iso, - WeekStart::monday(), - ); - assert!(matches!(cal.today(), DiaryPeriod::Week { .. })); -} - -#[test] -fn date_mode_weekly_steps_seven_days_from_the_week_start_monday() { - let cal = DiaryCalendar::new( - DiaryFrequency::Weekly, - WeeklyStyle::Date, - WeekStart::monday(), - ); - // From a Wednesday: snap back to Mon 05-25, then ±7. - assert_eq!(cal.next(day(2026, 5, 27)), day(2026, 6, 1)); - assert_eq!(cal.prev(day(2026, 5, 27)), day(2026, 5, 18)); - // From the Monday itself: no snap, just ±7. - assert_eq!(cal.next(day(2026, 5, 25)), day(2026, 6, 1)); - assert_eq!(cal.prev(day(2026, 5, 25)), day(2026, 5, 18)); - // Stem is the plain date, like upstream. - assert_eq!(cal.next(day(2026, 5, 27)).format(), "2026-06-01"); -} - -#[test] -fn date_mode_weekly_honours_a_sunday_week_start() { - let cal = DiaryCalendar::new( - DiaryFrequency::Weekly, - WeeklyStyle::Date, - WeekStart::parse("sunday"), - ); - // Wed 05-27 snaps back to Sun 05-24, +7 = Sun 05-31. - assert_eq!(cal.next(day(2026, 5, 27)), day(2026, 5, 31)); - assert_eq!(cal.prev(day(2026, 5, 27)), day(2026, 5, 17)); -} - -#[test] -fn iso_mode_weekly_delegates_to_iso_week_stepping() { - let cal = DiaryCalendar::new( - DiaryFrequency::Weekly, - WeeklyStyle::Iso, - WeekStart::monday(), - ); - let wk = DiaryPeriod::week_containing(DiaryDate::from_ymd(2026, 5, 27).unwrap()); - assert_eq!(cal.next(wk), wk.next()); - assert_eq!(cal.prev(wk), wk.prev()); -} - -#[test] -fn daily_calendar_steps_one_day_regardless_of_weekly_fields() { - let cal = DiaryCalendar::new( - DiaryFrequency::Daily, - WeeklyStyle::Date, - WeekStart::parse("sunday"), - ); - assert_eq!(cal.next(day(2026, 5, 27)), day(2026, 5, 28)); - assert_eq!(cal.prev(day(2026, 5, 27)), day(2026, 5, 26)); -} diff --git a/crates/nuwiki-core/tests/html_renderer.rs b/crates/nuwiki-core/tests/html_renderer.rs deleted file mode 100644 index 713728e..0000000 --- a/crates/nuwiki-core/tests/html_renderer.rs +++ /dev/null @@ -1,618 +0,0 @@ -//! End-to-end tests for the HTML renderer. -//! -//! Pipeline: source text → `VimwikiSyntax::parse` → `HtmlRenderer::render`. - -use nuwiki_core::ast::{ - BlockNode, DocumentNode, InlineNode, LinkTarget, Span, TableCellNode, TableNode, TableRowNode, - TextNode, -}; -use nuwiki_core::render::{HtmlRenderer, Renderer}; -use nuwiki_core::syntax::vimwiki::VimwikiSyntax; -use nuwiki_core::syntax::SyntaxPlugin; - -fn render(src: &str) -> String { - let doc = VimwikiSyntax::new().parse(src); - HtmlRenderer::new().render_to_string(&doc).unwrap() -} - -fn span_cell(text: &str, col_span: bool, row_span: bool) -> TableCellNode { - let s = Span::default(); - TableCellNode { - span: s, - children: vec![InlineNode::Text(TextNode { - span: s, - content: text.into(), - })], - col_span, - row_span, - } -} - -fn span_table(rows: Vec>, has_header: bool) -> DocumentNode { - let span = Span::default(); - let rows: Vec = rows - .into_iter() - .enumerate() - .map(|(i, cells)| TableRowNode { - span, - cells, - is_header: has_header && i == 0, - }) - .collect(); - DocumentNode { - span, - metadata: Default::default(), - children: vec![BlockNode::Table(TableNode { - span, - rows, - has_header, - alignments: Vec::new(), - })], - } -} - -// ===== Block elements ===== - -#[test] -fn empty_document_renders_to_empty_string() { - assert_eq!(render(""), ""); -} - -#[test] -fn heading_levels() { - for level in 1..=6 { - let bars = "=".repeat(level); - let out = render(&format!("{bars} Title {bars}\n")); - // vimwiki structure:
    - //
    . A lone heading has hier == flat. - assert!(out.starts_with(&format!( - "" - )), "got: {out}"); - } -} - -#[test] -fn centered_heading_gets_centered_class() { - let out = render(" = T =\n"); - assert!(out.contains("

    T

    ")); -} - -#[test] -fn spaceless_heading_renders_as_heading() { - // `==Heading==` (no surrounding spaces) is a heading, like vimwiki — not a - // literal `

    ==Heading==

    `. - let out = render("==Plain==\n"); - assert!( - out.contains( - "

    \ - Plain

    " - ), - "got: {out}" - ); - // The spaceless + keyword combo from the report: a header, not a badge. - let out2 = render("==TODO==\n"); - assert!( - out2.contains("

    "), - "got: {out2}" - ); - assert!(!out2.contains("

    "), "not a paragraph: {out2}"); -} - -#[test] -fn heading_keyword_renders_as_plain_text_not_badge() { - // `== TODO ==` is a section title, not an inline keyword: it must render as - // a normal header (with a correct, non-empty id), not a `` - // badge. Regression for the exported header losing its header styling + id. - let out = render("== TODO ==\n"); - assert!( - out.contains( - "

    \ - TODO

    " - ), - "got: {out}" - ); - assert!(!out.contains("class=\"todo\""), "no keyword badge: {out}"); - - // A keyword mid-title keeps its text in the id (no dropped word / double - // space) and still renders plainly. - let out2 = render("= My TODO list =\n"); - assert!(out2.contains("id=\"My TODO list\""), "got: {out2}"); - assert!(!out2.contains("class=\"todo\""), "got: {out2}"); -} - -#[test] -fn wikilink_description_keeps_inner_brackets() { - // A `]` inside a wikilink description (e.g. a `[TICKET-1]`) must not close - // the link early. Regression for descriptions with brackets being mangled. - let out = render("[[page|Task [B-1]]]\n"); - assert!( - out.contains("Task [B-1]"), - "got: {out}" - ); - // Two bracketed links on one line both resolve. - let out2 = render("[[a|X [1]]] and [[b|Y [2]]]\n"); - assert!(out2.contains("X [1]"), "got: {out2}"); - assert!(out2.contains("Y [2]"), "got: {out2}"); -} - -#[test] -fn paragraph_wraps_inline_content() { - let out = render("Hello world\n"); - assert_eq!(out.trim(), "

    Hello world

    "); -} - -#[test] -fn horizontal_rule() { - let out = render("----\n"); - assert!(out.contains("
    ")); -} - -#[test] -fn blockquote_lines_become_paragraphs() { - let out = render("> first\n> second\n"); - assert!(out.contains("
    ")); - assert!(out.contains("

    first

    ")); - assert!(out.contains("

    second

    ")); -} - -#[test] -fn preformatted_block_emits_pre_with_raw_fence_attrs() { - // vimwiki drops the verbatim fence text into the
     tag (`{{{rust` →
    -    // `
    `), with no inner  wrapper.
    -    let out = render("{{{rust\nfn main() {}\n}}}\n");
    -    assert!(out.contains("
    "), "got: {out}");
    -    assert!(out.contains("fn main() {}"));
    -    assert!(out.contains("
    ")); - assert!(!out.contains("plain"), "got: {out}"); -} - -#[test] -fn preformatted_block_keeps_full_attr_string() { - let out = render("{{{class=\"brush: python\"\nx\n}}}\n"); - assert!(out.contains("
    "), "got: {out}");
    -}
    -
    -#[test]
    -fn math_block_emits_div() {
    -    let out = render("{{$\nx=1\n}}$\n");
    -    assert!(out.contains("
    ")); - assert!(out.contains("x=1")); -} - -#[test] -fn unordered_list_with_checkbox() { - let out = render("- [X] done\n- not done\n"); - assert!(out.contains("
      ")); - // vimwiki-compatible: class on the
    • , no (the stylesheet draws it). - assert!(out.contains("
    • done
    • "), "{out}"); - assert!(out.contains("
    • not done
    • "), "{out}"); - assert!(!out.contains("a"), "{out}"); - assert!(out.contains("
    • b
    • "), "{out}"); - assert!(out.contains("
    • c
    • "), "{out}"); - assert!(out.contains("
    • d
    • "), "{out}"); - assert!(out.contains("
    • e
    • "), "{out}"); - assert!(out.contains("
    • f
    • "), "{out}"); - assert!(!out.contains("task-"), "no legacy task-* classes: {out}"); - assert!(!out.contains("")); - assert!(out.contains("
    • first
    • ")); -} - -#[test] -fn nested_list_renders_recursively() { - let out = render("- top\n - nested\n"); - assert!(out.contains("
        ")); - let inner_idx = out - .find("
          \n
        • top") - .map(|i| i + "
            \n
          • top".len()) - .unwrap_or(0); - assert!(out[inner_idx..].contains("
              ")); - assert!(out.contains("nested")); -} - -#[test] -fn definition_list() { - let out = render("Term:: Defn\n"); - assert!(out.contains("
              ")); - assert!(out.contains("
              Term
              ")); - assert!(out.contains("
              Defn
              ")); -} - -#[test] -fn table_with_header_separator() { - let out = render("| a | b |\n|---|---|\n| 1 | 2 |\n"); - assert!(out.contains("")); - assert!(out.contains("")); - assert!(out.contains("")); - assert!(out.contains("")); - assert!(out.contains("")); -} - -#[test] -fn single_comment_becomes_html_comment() { - let out = render("%% hidden\n"); - assert!(out.contains("")); -} - -// ===== Inline ===== - -#[test] -fn bold_italic_strike_super_sub() { - let out = render("*b* _i_ ~~s~~ ^sup^ ,,sub,,\n"); - assert!(out.contains("b")); - assert!(out.contains("i")); - assert!(out.contains("s")); - assert!(out.contains("sup")); - assert!(out.contains("sub")); -} - -#[test] -fn inline_code_is_escaped() { - let out = render("Use `Vec` here\n"); - assert!(out.contains("Vec<u32>")); -} - -#[test] -fn keyword_spans() { - // One class per keyword so a stylesheet can colour groups differently. - // TODO keeps the vimwiki `todo` class. - let cases = [ - ("TODO x\n", "TODO"), - ("DONE x\n", "DONE"), - ("STARTED x\n", "STARTED"), - ("FIXME x\n", "FIXME"), - ("FIXED x\n", "FIXED"), - ("XXX x\n", "XXX"), - ("STOPPED x\n", "STOPPED"), - ]; - for (src, expected) in cases { - let out = render(src); - assert!(out.contains(expected), "src {src:?} -> {out}"); - } -} - -#[test] -fn raw_url_link() { - let out = render("See https://example.com here\n"); - assert!(out.contains("https://example.com")); -} - -#[test] -fn wikilink_with_default_resolver() { - let out = render("[[Page]]\n"); - assert!(out.contains("Page")); -} - -#[test] -fn wikilink_with_anchor_default_resolver() { - let out = render("[[Page#Section]]\n"); - assert!(out.contains("")); -} - -#[test] -fn anchor_only_link() { - let out = render("[[#Section]]\n"); - assert!(out.contains("")); -} - -#[test] -fn heading_id_matches_anchor_link_href() { - // The heading carries an `id` equal to its plain text, so a `#anchor` - // link (TOC or cross-reference) actually lands on it. Regression guard - // for "exported HTML anchor links don't work". - let out = render("= My Section =\n\n[[#My Section]]\n"); - assert!( - out.contains( - "" - ), - "heading id: {out}" - ); - assert!( - out.contains(""), - "anchor href: {out}" - ); -} - -#[test] -fn toc_header_heading_wrapped_in_div_toc() { - // The TOC section heading gets a `
              ` wrapper (vimwiki - // scheme) so `.toc` stylesheet rules apply. The class is on the div, not - // the heading. - let doc = VimwikiSyntax::new().parse("== Contents ==\n"); - let out = HtmlRenderer::new() - .with_toc_header("Contents") - .render_to_string(&doc) - .unwrap(); - assert!( - out.contains("

              Contents

              "), - "got: {out}" - ); -} - -#[test] -fn non_toc_heading_not_wrapped() { - let doc = VimwikiSyntax::new().parse("== Intro ==\n"); - let out = HtmlRenderer::new() - .with_toc_header("Contents") - .render_to_string(&doc) - .unwrap(); - assert!( - out.contains( - "
              " - ), - "got: {out}" - ); - assert!(!out.contains("class=\"toc\""), "got: {out}"); -} - -#[test] -fn interwiki_numbered_link() { - let out = render("[[wiki1:Page]]\n"); - assert!(out.contains("")); -} - -#[test] -fn interwiki_named_link() { - let out = render("[[wn.Notes:Page]]\n"); - assert!(out.contains("")); -} - -#[test] -fn diary_link() { - let out = render("[[diary:2026-05-10]]\n"); - assert!(out.contains("")); -} - -#[test] -fn external_link_routes_to_external_link_node() { - let out = render("[[https://example.com|docs]]\n"); - assert!(out.contains("docs")); -} - -#[test] -fn transclusion_becomes_img() { - let out = render("{{img.png|cat photo|class=hi}}\n"); - assert!(out.contains(" c & d \"e\"\n"); - assert!(out.contains("a < b > c & d "e"")); -} - -// ===== Custom link resolver ===== - -#[test] -fn custom_link_resolver_overrides_href() { - let doc = VimwikiSyntax::new().parse("[[Page]]\n"); - let renderer = HtmlRenderer::new() - .with_link_resolver(|t: &LinkTarget| format!("/wiki/{}", t.path.as_deref().unwrap_or(""))); - let out = renderer.render_to_string(&doc).unwrap(); - assert!(out.contains("")); -} - -// ===== Template ===== - -#[test] -fn template_substitutes_content_and_title() { - let doc = VimwikiSyntax::new().parse("%title My Page\n= Hello =\n"); - let renderer = HtmlRenderer::new().with_template( - "{{title}}\ - {{content}}", - ); - let out = renderer.render_to_string(&doc).unwrap(); - assert!(out.contains("My Page")); - assert!(out.contains("")); -} - -#[test] -fn template_with_missing_title_substitutes_empty_string() { - let doc = VimwikiSyntax::new().parse("= Heading =\n"); - let renderer = HtmlRenderer::new().with_template("{{title}}{{content}}"); - let out = renderer.render_to_string(&doc).unwrap(); - assert!(out.contains("")); - assert!(out.contains("")); -} - -#[test] -fn template_substitutes_vimwiki_percent_placeholders() { - // Stock vimwiki templates use `%title%` / `%content%` / `%root_path%` - // rather than nuwiki's `{{…}}`. Both must resolve. - let doc = VimwikiSyntax::new().parse("%title My Page\n= Hello =\n"); - let renderer = HtmlRenderer::new() - .with_template( - "%title%%content%", - ) - .with_var("root_path", "../"); - let out = renderer.render_to_string(&doc).unwrap(); - assert!(out.contains("My Page"), "title: {out}"); - assert!( - out.contains(""), - "content: {out}" - ); - assert!(out.contains("href=\"../style.css\""), "root_path: {out}"); - assert!(!out.contains('%'), "no placeholder left: {out}"); -} - -// ===== Colour spans ===== - -// `ColorNode` is an extension point the vimwiki parser never emits, so build -// a document around one by hand and render it directly. -fn doc_with_color(color: &str) -> DocumentNode { - use nuwiki_core::ast::{ColorNode, ParagraphNode}; - let color_node = InlineNode::Color(ColorNode { - span: Span::default(), - color: color.to_string(), - children: vec![InlineNode::Text(TextNode { - span: Span::default(), - content: "hi".to_string(), - })], - }); - DocumentNode { - children: vec![BlockNode::Paragraph(ParagraphNode { - span: Span::default(), - children: vec![color_node], - })], - ..DocumentNode::default() - } -} - -#[test] -fn color_dic_name_uses_inline_style_via_default_template() { - let doc = doc_with_color("red"); - let renderer = - HtmlRenderer::new().with_colors([("red".to_string(), "crimson".to_string())].into()); - let out = renderer.render_to_string(&doc).unwrap(); - assert!( - out.contains("hi"), - "got: {out}" - ); -} - -#[test] -fn color_tag_template_override_is_honored() { - let doc = doc_with_color("red"); - let renderer = HtmlRenderer::new() - .with_colors([("red".to_string(), "crimson".to_string())].into()) - .with_color_template("__CONTENT__"); - let out = renderer.render_to_string(&doc).unwrap(); - assert!( - out.contains("hi"), - "got: {out}" - ); -} - -#[test] -fn unknown_color_name_falls_back_to_class() { - let doc = doc_with_color("plaid"); - let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); - assert!( - out.contains("hi"), - "got: {out}" - ); -} - -// ===== End-to-end smoke ===== - -#[test] -fn full_document_round_trip() { - let src = "\ -%title Smoke -= Heading = - -Paragraph with *bold* and _italic_ and `code`. - -- one -- two - -> quoted - ----- - -| a | b | -|---|---| -| 1 | 2 | - -{{{rust -fn x() {} -}}} -"; - let doc = VimwikiSyntax::new().parse(src); - let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); - // A few sanity checks; the full output is exercised piece-by-piece above. - for needle in [ - "", - "bold", - "italic", - "code", - "
                ", - "
                ", - "
                ", - "
              a
              1
              ", - "
              ",
              -    ] {
              -        assert!(
              -            out.contains(needle),
              -            "expected {needle:?} in output:\n{out}"
              -        );
              -    }
              -}
              -
              -// ===== Table colspan / rowspan =====
              -
              -#[test]
              -fn colspan_marker_folds_into_lead_cell() {
              -    // | a | b | c |
              -    // | d | > | e |    → second row has b spanning 2 cols (cell index 1 is >)
              -    let doc = span_table(
              -        vec![
              -            vec![
              -                span_cell("a", false, false),
              -                span_cell("b", false, false),
              -                span_cell("c", false, false),
              -            ],
              -            vec![
              -                span_cell("d", false, false),
              -                span_cell(">", true, false),
              -                span_cell("e", false, false),
              -            ],
              -        ],
              -        false,
              -    );
              -    let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
              -    assert!(out.contains(r#"
              "#), "got: {out}"); - assert!(!out.contains(r#"class="col-span""#)); -} - -#[test] -fn rowspan_marker_folds_into_lead_cell_above() { - let doc = span_table( - vec![ - vec![span_cell("a", false, false), span_cell("b", false, false)], - vec![span_cell("c", false, false), span_cell("\\/", false, true)], - ], - false, - ); - let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); - assert!(out.contains(r#""#), "got: {out}"); -} - -#[test] -fn no_spans_emits_no_table_attrs() { - let doc = span_table( - vec![ - vec![span_cell("a", false, false), span_cell("b", false, false)], - vec![span_cell("c", false, false), span_cell("d", false, false)], - ], - false, - ); - let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); - assert!(!out.contains("colspan=")); - assert!(!out.contains("rowspan=")); -} diff --git a/crates/nuwiki-core/tests/lexer.rs b/crates/nuwiki-core/tests/lexer.rs deleted file mode 100644 index b3aab42..0000000 --- a/crates/nuwiki-core/tests/lexer.rs +++ /dev/null @@ -1,673 +0,0 @@ -//! End-to-end tests for the vimwiki lexer. -//! -//! Each test feeds a small fragment to `VimwikiLexer::lex` and asserts the -//! exact token-kind sequence. A separate `spans_are_byte_accurate` test -//! pins down position semantics. - -use std::collections::HashMap; - -use nuwiki_core::ast::{CheckboxState, Keyword, ListSymbol, Position}; -use nuwiki_core::syntax::vimwiki::{VimwikiLexer, VimwikiTokenKind}; -use nuwiki_core::syntax::Lexer; - -use VimwikiTokenKind::*; - -fn lex(text: &str) -> Vec { - VimwikiLexer::new() - .lex(text) - .into_iter() - .map(|t| t.kind) - .collect() -} - -fn text(s: &str) -> VimwikiTokenKind { - Text(s.into()) -} - -// ===== Empty / whitespace ===== - -#[test] -fn empty_input_produces_no_tokens() { - assert!(lex("").is_empty()); -} - -#[test] -fn lone_newline_is_a_blank_line() { - assert_eq!(lex("\n"), vec![BlankLine]); -} - -#[test] -fn whitespace_only_line_is_a_blank_line() { - assert_eq!(lex(" \n"), vec![BlankLine]); -} - -#[test] -fn blank_lines_separate_paragraphs() { - assert_eq!( - lex("hello\n\nworld\n"), - vec![text("hello"), Newline, BlankLine, text("world"), Newline] - ); -} - -// ===== Headings ===== - -#[test] -fn headings_at_all_six_levels() { - let mut tokens = Vec::new(); - for level in 1..=6 { - let line = format!( - "{} L{} {}\n", - "=".repeat(level as usize), - level, - "=".repeat(level as usize) - ); - let lexed = lex(&line); - assert!(matches!( - lexed[0], - HeadingOpen { level: l, centered: false } if l == level - )); - tokens.extend(lexed); - } - assert!(tokens.iter().any(|t| matches!(t, HeadingClose))); -} - -#[test] -fn centered_heading_is_marked_centered() { - let lexed = lex(" = title =\n"); - assert!(matches!( - lexed[0], - HeadingOpen { - level: 1, - centered: true - } - )); - assert_eq!(lexed[1], text("title")); - assert!(matches!(lexed[2], HeadingClose)); -} - -#[test] -fn spaceless_heading_is_a_heading() { - // vimwiki accepts `==Heading==` with no spaces around the text. - let lexed = lex("==Plain==\n"); - assert_eq!( - lexed, - vec![ - HeadingOpen { - level: 2, - centered: false - }, - text("Plain"), - HeadingClose, - Newline, - ] - ); -} - -#[test] -fn marker_only_or_unbalanced_lines_are_not_headings() { - // No title between markers, and trailing `=` not at line end → plain text. - assert!(!matches!(lex("======\n")[0], HeadingOpen { .. })); - assert!(!matches!(lex("==> arrow\n")[0], HeadingOpen { .. })); - assert!(!matches!(lex("==a==b\n")[0], HeadingOpen { .. })); -} - -#[test] -fn heading_with_inline_bold() { - let lexed = lex("== Hello *world* ==\n"); - assert_eq!( - lexed, - vec![ - HeadingOpen { - level: 2, - centered: false - }, - text("Hello "), - BoldDelim, - text("world"), - BoldDelim, - HeadingClose, - Newline, - ] - ); -} - -// ===== Horizontal rule ===== - -#[test] -fn horizontal_rule_requires_four_or_more_dashes() { - assert_eq!(lex("----\n"), vec![HorizontalRule, Newline]); - assert_eq!(lex("--------\n"), vec![HorizontalRule, Newline]); - // Three dashes: not a rule, parsed as text. - assert_eq!(lex("---\n"), vec![text("---"), Newline]); -} - -// ===== Comments ===== - -#[test] -fn single_line_comment() { - assert_eq!( - lex("%% hello\n"), - vec![CommentLine(" hello".into()), Newline] - ); -} - -#[test] -fn multiline_comment_spans_multiple_lines() { - assert_eq!( - lex("%%+ a\nb\n+%%\n"), - vec![ - CommentMultiOpen, - CommentMultiLine(" a".into()), - Newline, - CommentMultiLine("b".into()), - Newline, - CommentMultiClose, - Newline, - ] - ); -} - -#[test] -fn multiline_comment_one_liner() { - assert_eq!( - lex("%%+ inline +%%\n"), - vec![ - CommentMultiOpen, - CommentMultiLine(" inline ".into()), - CommentMultiClose, - Newline, - ] - ); -} - -// ===== Preformatted ===== - -#[test] -fn preformatted_block_captures_lines_verbatim() { - let lexed = lex("{{{\nfn main() {}\n}}}\n"); - assert!(matches!(lexed[0], PreformattedOpen { .. })); - assert_eq!(lexed[1], Newline); - assert_eq!(lexed[2], PreformattedLine("fn main() {}".into())); - assert_eq!(lexed[3], Newline); - assert_eq!(lexed[4], PreformattedClose); - assert_eq!(lexed[5], Newline); -} - -#[test] -fn preformatted_block_parses_language() { - let lexed = lex("{{{rust\nx\n}}}\n"); - let PreformattedOpen { language, .. } = &lexed[0] else { - panic!("expected PreformattedOpen"); - }; - assert_eq!(language.as_deref(), Some("rust")); -} - -#[test] -fn preformatted_block_parses_attrs() { - let lexed = lex("{{{rust class=\"hl\" key=val\nx\n}}}\n"); - let PreformattedOpen { - language, - attrs, - raw, - .. - } = &lexed[0] - else { - panic!("expected PreformattedOpen"); - }; - assert_eq!(language.as_deref(), Some("rust")); - let mut expected = HashMap::new(); - expected.insert("class".into(), "hl".into()); - expected.insert("key".into(), "val".into()); - assert_eq!(attrs, &expected); - // The verbatim fence text is preserved for the HTML renderer. - assert_eq!(raw, "rust class=\"hl\" key=val"); -} - -// ===== Math block ===== - -#[test] -fn math_block_captures_environment() { - let lexed = lex("{{$%align%\nx = 1\n}}$\n"); - assert_eq!( - lexed[0], - MathBlockOpen { - environment: Some("align".into()) - } - ); - assert_eq!(lexed[2], MathBlockLine("x = 1".into())); - assert_eq!(lexed[4], MathBlockClose); -} - -// ===== Placeholders ===== - -#[test] -fn all_four_placeholders() { - assert_eq!( - lex("%title My Page\n"), - vec![PlaceholderTitle(Some("My Page".into())), Newline] - ); - assert_eq!(lex("%nohtml\n"), vec![PlaceholderNohtml, Newline]); - assert_eq!( - lex("%template fancy\n"), - vec![PlaceholderTemplate(Some("fancy".into())), Newline] - ); - assert_eq!( - lex("%date 2026-05-10\n"), - vec![PlaceholderDate(Some("2026-05-10".into())), Newline] - ); -} - -// ===== Lists ===== - -#[test] -fn list_marker_dash() { - let lexed = lex("- item\n"); - assert_eq!( - lexed, - vec![ - ListMarker { - symbol: ListSymbol::Dash, - indent: 0 - }, - text("item"), - Newline, - ] - ); -} - -#[test] -fn list_marker_star_with_space_is_a_list_not_bold() { - let lexed = lex("* item\n"); - assert_eq!( - lexed[0], - ListMarker { - symbol: ListSymbol::Star, - indent: 0 - } - ); - assert_eq!(lexed[1], text("item")); -} - -#[test] -fn list_marker_kinds() { - let cases: &[(&str, ListSymbol)] = &[ - ("- a\n", ListSymbol::Dash), - ("* a\n", ListSymbol::Star), - ("# a\n", ListSymbol::Hash), - ("1. a\n", ListSymbol::Numeric), - ("1) a\n", ListSymbol::NumericParen), - ("a) a\n", ListSymbol::AlphaParen), - ("A) a\n", ListSymbol::AlphaUpperParen), - ("i) a\n", ListSymbol::RomanParen), - ("I) a\n", ListSymbol::RomanUpperParen), - ]; - for (line, expected) in cases { - let lexed = lex(line); - assert!( - matches!(lexed[0], ListMarker { symbol, .. } if symbol == *expected), - "input {line:?} expected {expected:?}, got {:?}", - lexed[0] - ); - } -} - -#[test] -fn list_marker_at_indent() { - let lexed = lex(" - nested\n"); - assert_eq!( - lexed[0], - ListMarker { - symbol: ListSymbol::Dash, - indent: 4 - } - ); -} - -#[test] -fn checkbox_states() { - let cases: &[(&str, CheckboxState)] = &[ - ("- [ ] a\n", CheckboxState::Empty), - ("- [.] a\n", CheckboxState::Quarter), - ("- [o] a\n", CheckboxState::Half), - ("- [O] a\n", CheckboxState::ThreeQuarters), - ("- [X] a\n", CheckboxState::Done), - ("- [-] a\n", CheckboxState::Rejected), - ]; - for (line, expected) in cases { - let lexed = lex(line); - assert!( - matches!(lexed[1], Checkbox(s) if s == *expected), - "input {line:?} expected {expected:?}, got {:?}", - lexed[1] - ); - } -} - -#[test] -fn checkbox_states_honor_custom_palette() { - use nuwiki_core::listsyms::ListSyms; - - let lex_with = |text: &str, syms: ListSyms| -> Vec { - VimwikiLexer::new() - .with_listsyms(syms) - .lex(text) - .into_iter() - .map(|t| t.kind) - .collect() - }; - - // 3-glyph palette ` x✓`: empty / half / done, plus the fixed rejected `-`. - let syms = ListSyms::new(" x✓"); - let cases: &[(&str, CheckboxState)] = &[ - ("- [ ] a\n", CheckboxState::Empty), - ("- [x] a\n", CheckboxState::Half), - ("- [✓] a\n", CheckboxState::Done), - ("- [-] a\n", CheckboxState::Rejected), - ]; - for (line, expected) in cases { - let lexed = lex_with(line, syms.clone()); - assert!( - matches!(lexed[1], Checkbox(s) if s == *expected), - "input {line:?} expected {expected:?}, got {:?}", - lexed[1] - ); - } - - // Default-palette glyphs are not checkboxes under this palette: `[o]` - // is plain inline text, so no Checkbox token is emitted. - let lexed = lex_with("- [o] a\n", syms); - assert!( - !lexed.iter().any(|k| matches!(k, Checkbox(_))), - "expected no checkbox token, got {lexed:?}" - ); -} - -// ===== Blockquotes ===== - -#[test] -fn angle_blockquote() { - assert_eq!( - lex("> quoted\n"), - vec![BlockquoteMarker, text("quoted"), Newline] - ); -} - -#[test] -fn indent_blockquote() { - assert_eq!( - lex(" quoted\n"), - vec![BlockquoteIndent, text("quoted"), Newline] - ); -} - -#[test] -fn indent_with_list_marker_is_a_list_not_blockquote() { - let lexed = lex(" - item\n"); - assert!(matches!(lexed[0], ListMarker { indent: 4, .. })); -} - -// ===== Tables ===== - -#[test] -fn simple_table_row() { - assert_eq!( - lex("| a | b |\n"), - vec![ - TableSep, - text(" a "), - TableSep, - text(" b "), - TableSep, - Newline, - ] - ); -} - -#[test] -fn table_header_separator_row() { - use nuwiki_core::ast::TableAlign; - assert_eq!( - lex("|---|---|\n"), - vec![ - TableHeaderRow(vec![TableAlign::Default, TableAlign::Default]), - Newline - ] - ); -} - -#[test] -fn table_col_and_row_span_cells() { - let lexed = lex("| a | > | \\/ |\n"); - // sep, " a ", sep, ColSpan, sep, RowSpan, sep, newline - assert_eq!( - lexed, - vec![ - TableSep, - text(" a "), - TableSep, - TableColSpan, - TableSep, - TableRowSpan, - TableSep, - Newline, - ] - ); -} - -// ===== Definition list ===== - -#[test] -fn definition_term_with_inline_definition() { - let lexed = lex("Term:: Definition\n"); - assert_eq!( - lexed, - vec![ - text("Term"), - DefinitionTermMarker, - text("Definition"), - Newline, - ] - ); -} - -#[test] -fn definition_term_alone() { - let lexed = lex("Term::\n"); - assert_eq!(lexed, vec![text("Term"), DefinitionTermMarker, Newline]); -} - -// ===== Inline typefaces ===== - -#[test] -fn inline_bold_italic_strike_super_sub() { - assert_eq!( - lex("*b* _i_ ~~s~~ ^sup^ ,,sub,,\n"), - vec![ - BoldDelim, - text("b"), - BoldDelim, - text(" "), - ItalicDelim, - text("i"), - ItalicDelim, - text(" "), - StrikethroughDelim, - text("s"), - StrikethroughDelim, - text(" "), - SuperscriptDelim, - text("sup"), - SuperscriptDelim, - text(" "), - SubscriptDelim, - text("sub"), - SubscriptDelim, - Newline, - ] - ); -} - -#[test] -fn inline_code_captures_content() { - assert_eq!(lex("`x = 1`\n"), vec![Code("x = 1".into()), Newline]); -} - -#[test] -fn inline_math_captures_content() { - assert_eq!( - lex("$E = mc^2$\n"), - vec![MathInline("E = mc^2".into()), Newline] - ); -} - -// ===== Keywords ===== - -#[test] -fn all_keywords() { - let cases: &[(&str, Keyword)] = &[ - ("TODO", Keyword::Todo), - ("DONE", Keyword::Done), - ("STARTED", Keyword::Started), - ("FIXME", Keyword::Fixme), - ("FIXED", Keyword::Fixed), - ("XXX", Keyword::Xxx), - ("STOPPED", Keyword::Stopped), - ]; - for (s, expected) in cases { - let line = format!("{s} stuff\n"); - let lexed = lex(&line); - assert!( - matches!(lexed[0], Keyword(k) if k == *expected), - "input {s:?} expected {expected:?}, got {:?}", - lexed[0] - ); - } -} - -#[test] -fn keyword_only_at_word_boundary() { - // Embedded TODO inside a longer word should NOT match as a keyword. - let lexed = lex("aTODOb\n"); - assert_eq!(lexed, vec![text("aTODOb"), Newline]); -} - -// ===== Wikilinks / transclusions / raw URLs ===== - -#[test] -fn bare_wikilink() { - assert_eq!( - lex("[[Page]]\n"), - vec![WikiLinkOpen, text("Page"), WikiLinkClose, Newline] - ); -} - -#[test] -fn described_wikilink() { - assert_eq!( - lex("[[Page|Description]]\n"), - vec![ - WikiLinkOpen, - text("Page"), - WikiLinkSep, - text("Description"), - WikiLinkClose, - Newline, - ] - ); -} - -#[test] -fn wikilink_with_anchor_is_lexed_as_text() { - // The lexer doesn't carve out anchors; the parser does. - let lexed = lex("[[Page#Anchor]]\n"); - assert_eq!( - lexed, - vec![WikiLinkOpen, text("Page#Anchor"), WikiLinkClose, Newline,] - ); -} - -#[test] -fn transclusion_with_alt_and_attrs() { - assert_eq!( - lex("{{img.png|alt|class=hi}}\n"), - vec![ - TransclusionOpen, - text("img.png"), - TransclusionSep, - text("alt"), - TransclusionSep, - text("class=hi"), - TransclusionClose, - Newline, - ] - ); -} - -#[test] -fn raw_urls_for_each_supported_scheme() { - for scheme in &[ - "https://example.com", - "http://example.com/path?q=1", - "ftp://archive.example.com/file.tar.gz", - "mailto:hi@example.com", - "file:///tmp/file.txt", - ] { - let line = format!("see {scheme} now\n"); - let lexed = lex(&line); - assert!( - lexed.iter().any(|t| matches!(t, RawUrl(u) if u == scheme)), - "scheme {scheme:?} not lexed; got {lexed:?}" - ); - } -} - -#[test] -fn raw_url_drops_trailing_sentence_punctuation() { - let lexed = lex("Visit https://example.com.\n"); - assert!( - lexed - .iter() - .any(|t| matches!(t, RawUrl(u) if u == "https://example.com")), - "got {lexed:?}" - ); -} - -// ===== Span correctness ===== - -#[test] -fn spans_are_byte_accurate() { - // Input layout: - // Line 0: "= H =" - // Line 1: "" (blank line) - // Line 2: "*bold*" - let src = "= H =\n\n*bold*\n"; - let tokens = VimwikiLexer::new().lex(src).into_vec(); - - // First token: HeadingOpen at line 0, col 0..1 - assert_eq!( - tokens[0].span.start, - Position { - line: 0, - column: 0, - offset: 0 - } - ); - assert_eq!( - tokens[0].span.end, - Position { - line: 0, - column: 1, - offset: 1 - } - ); - - // Find the BoldDelim opening on line 2. - let bold_open = tokens - .iter() - .find(|t| matches!(t.kind, BoldDelim)) - .expect("bold delim"); - assert_eq!(bold_open.span.start.line, 2); - assert_eq!(bold_open.span.start.column, 0); - // "= H =\n" = 6 bytes, "\n" = 1, so line 2 starts at offset 7. - assert_eq!(bold_open.span.start.offset, 7); -} diff --git a/crates/nuwiki-core/tests/lists.rs b/crates/nuwiki-core/tests/lists.rs deleted file mode 100644 index ac39a77..0000000 --- a/crates/nuwiki-core/tests/lists.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! Cluster 8b — multi-line list-item continuation. -//! -//! Lines indented strictly past the item's marker attach to the item -//! rather than becoming a sibling paragraph. Mirrors upstream vimwiki -//! + Markdown. - -use nuwiki_core::ast::{BlockNode, InlineNode}; -use nuwiki_core::render::{HtmlRenderer, Renderer}; -use nuwiki_core::syntax::vimwiki::VimwikiSyntax; -use nuwiki_core::syntax::SyntaxPlugin; - -fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { - VimwikiSyntax::new().parse(src) -} - -fn single_list(doc: &nuwiki_core::ast::DocumentNode) -> &nuwiki_core::ast::ListNode { - match &doc.children[0] { - BlockNode::List(l) => l, - other => panic!("expected list, got {other:?}"), - } -} - -fn text_of(inlines: &[InlineNode]) -> String { - let mut out = String::new(); - for n in inlines { - match n { - InlineNode::Text(t) => out.push_str(&t.content), - InlineNode::Bold(b) => out.push_str(&text_of(&b.children)), - InlineNode::Italic(i) => out.push_str(&text_of(&i.children)), - // Soft breaks join continuation lines (formerly a Text(" ")). - InlineNode::SoftBreak(_) => out.push(' '), - _ => {} - } - } - out -} - -#[test] -fn indented_line_after_marker_continues_the_item() { - let doc = parse("- first item\n continuing here\n- second\n"); - assert_eq!(doc.children.len(), 1, "should be a single list block"); - let list = single_list(&doc); - assert_eq!(list.items.len(), 2); - let first = &list.items[0]; - let joined = text_of(&first.children); - assert_eq!(joined, "first item continuing here"); - let second = &list.items[1]; - assert_eq!(text_of(&second.children), "second"); -} - -#[test] -fn two_continuation_lines_both_attach() { - let doc = parse("- top\n middle\n bottom\n"); - let list = single_list(&doc); - assert_eq!(list.items.len(), 1); - let joined = text_of(&list.items[0].children); - assert_eq!(joined, "top middle bottom"); -} - -#[test] -fn unindented_line_breaks_the_list() { - let doc = parse("- item\nflush against margin\n"); - // Two blocks: a list (just `- item`) and a paragraph. - assert!( - doc.children.len() >= 2, - "want list + paragraph, got {}", - doc.children.len() - ); - match &doc.children[0] { - BlockNode::List(l) => { - assert_eq!(text_of(&l.items[0].children), "item"); - } - other => panic!("expected list first, got {other:?}"), - } -} - -#[test] -fn blank_line_breaks_the_list_item() { - // A blank line ends the current list (paragraph semantics). - let doc = parse("- item\n\n orphaned\n"); - let list = single_list(&doc); - assert_eq!(list.items.len(), 1); - assert_eq!(text_of(&list.items[0].children), "item"); -} - -#[test] -fn continuation_works_for_nested_lists_too() { - let doc = parse("- parent\n - child\n continued child\n- sibling\n"); - let list = single_list(&doc); - assert_eq!(list.items.len(), 2); - let parent = &list.items[0]; - let sublist = parent.sublist.as_ref().expect("nested list"); - assert_eq!(sublist.items.len(), 1); - let child = &sublist.items[0]; - assert_eq!(text_of(&child.children), "child continued child"); -} - -#[test] -fn html_renderer_emits_a_single_li_per_item() { - let doc = parse("- first item\n continuing here\n- second\n"); - let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); - // Single
                , two
              • entries — the bug we're fixing produced - // two adjacent
                  elements with an interleaved

                  . - assert_eq!(out.matches("

                    ").count(), 1, "got: {out}"); - assert_eq!(out.matches("
                  • ").count(), 2, "got: {out}"); - assert!(!out.contains("

                    "), "no orphan paragraph; got: {out}"); - assert!(out.contains("first item continuing here"), "got: {out}"); -} diff --git a/crates/nuwiki-core/tests/parser.rs b/crates/nuwiki-core/tests/parser.rs deleted file mode 100644 index f306b29..0000000 --- a/crates/nuwiki-core/tests/parser.rs +++ /dev/null @@ -1,592 +0,0 @@ -//! End-to-end tests for the vimwiki parser. Each test runs source text -//! through the lexer + parser and asserts on the resulting AST. - -use nuwiki_core::ast::{BlockNode, CheckboxState, InlineNode, Keyword, LinkKind, ListSymbol}; -use nuwiki_core::syntax::vimwiki::VimwikiSyntax; -use nuwiki_core::syntax::SyntaxPlugin; - -fn parse(text: &str) -> nuwiki_core::ast::DocumentNode { - VimwikiSyntax::new().parse(text) -} - -// ===== Document scaffolding ===== - -#[test] -fn empty_input_yields_empty_document() { - let doc = parse(""); - assert!(doc.children.is_empty()); - assert_eq!(doc.metadata.title, None); -} - -#[test] -fn placeholders_populate_metadata() { - let doc = parse("%title My Page\n%nohtml\n%template fancy\n%date 2026-05-10\n"); - assert_eq!(doc.metadata.title.as_deref(), Some("My Page")); - assert!(doc.metadata.nohtml); - assert_eq!(doc.metadata.template.as_deref(), Some("fancy")); - assert_eq!(doc.metadata.date.as_deref(), Some("2026-05-10")); - // No body blocks. - assert!(doc.children.is_empty()); -} - -// ===== Headings ===== - -#[test] -fn heading_each_level() { - for level in 1..=6 { - let line = format!( - "{} L{} {}\n", - "=".repeat(level as usize), - level, - "=".repeat(level as usize) - ); - let doc = parse(&line); - let BlockNode::Heading(h) = &doc.children[0] else { - panic!("expected heading at level {level}"); - }; - assert_eq!(h.level, level); - assert!(!h.centered); - } -} - -#[test] -fn centered_heading_flag() { - let doc = parse(" = title =\n"); - let BlockNode::Heading(h) = &doc.children[0] else { - panic!("expected heading"); - }; - assert!(h.centered); -} - -#[test] -fn heading_inline_bold() { - let doc = parse("== Hello *world* ==\n"); - let BlockNode::Heading(h) = &doc.children[0] else { - panic!("expected heading"); - }; - // Children: Text("Hello "), Bold(Text("world")) - assert_eq!(h.children.len(), 2); - assert!(matches!(&h.children[0], InlineNode::Text(t) if t.content == "Hello ")); - let InlineNode::Bold(b) = &h.children[1] else { - panic!("expected Bold"); - }; - assert!(matches!(&b.children[0], InlineNode::Text(t) if t.content == "world")); -} - -// ===== Paragraphs ===== - -#[test] -fn paragraph_simple_text() { - let doc = parse("Hello world\n"); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - assert_eq!(p.children.len(), 1); - assert!(matches!(&p.children[0], InlineNode::Text(t) if t.content == "Hello world")); -} - -#[test] -fn paragraph_spans_multiple_lines() { - let doc = parse("Hello\nworld\n"); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - // Contents: Text("Hello"), SoftBreak (from soft newline), Text("world"). - let text_concat: String = p - .children - .iter() - .filter_map(|n| match n { - InlineNode::Text(t) => Some(t.content.clone()), - InlineNode::SoftBreak(_) => Some(" ".to_string()), - _ => None, - }) - .collect(); - assert_eq!(text_concat, "Hello world"); -} - -#[test] -fn blank_line_breaks_paragraph() { - let doc = parse("first\n\nsecond\n"); - assert_eq!(doc.children.len(), 2); - assert!(matches!(doc.children[0], BlockNode::Paragraph(_))); - assert!(matches!(doc.children[1], BlockNode::Paragraph(_))); -} - -// ===== Horizontal rule ===== - -#[test] -fn horizontal_rule() { - let doc = parse("----\n"); - assert!(matches!(doc.children[0], BlockNode::HorizontalRule(_))); -} - -// ===== Lists ===== - -#[test] -fn flat_unordered_list() { - let doc = parse("- one\n- two\n- three\n"); - let BlockNode::List(list) = &doc.children[0] else { - panic!("expected list"); - }; - assert!(!list.ordered); - assert_eq!(list.symbol, ListSymbol::Dash); - assert_eq!(list.items.len(), 3); - for (item, expected) in list.items.iter().zip(["one", "two", "three"]) { - assert!(matches!(&item.children[0], InlineNode::Text(t) if t.content == expected)); - } -} - -#[test] -fn ordered_list_each_marker_kind() { - let cases: &[(&str, ListSymbol, bool)] = &[ - ("- a\n", ListSymbol::Dash, false), - ("* a\n", ListSymbol::Star, false), - ("# a\n", ListSymbol::Hash, false), - ("1. a\n", ListSymbol::Numeric, true), - ("1) a\n", ListSymbol::NumericParen, true), - ("a) a\n", ListSymbol::AlphaParen, true), - ("A) a\n", ListSymbol::AlphaUpperParen, true), - ("i) a\n", ListSymbol::RomanParen, true), - ("I) a\n", ListSymbol::RomanUpperParen, true), - ]; - for (src, sym, ordered) in cases { - let doc = parse(src); - let BlockNode::List(list) = &doc.children[0] else { - panic!("expected list for {src:?}"); - }; - assert_eq!(list.symbol, *sym); - assert_eq!(list.ordered, *ordered); - } -} - -#[test] -fn nested_list_via_indent() { - let doc = parse("- top\n - nested\n"); - let BlockNode::List(list) = &doc.children[0] else { - panic!("expected list"); - }; - let item = &list.items[0]; - let sublist = item.sublist.as_ref().expect("sublist on nested item"); - assert_eq!(sublist.items.len(), 1); - assert!(matches!(&sublist.items[0].children[0], InlineNode::Text(t) if t.content == "nested")); -} - -#[test] -fn list_item_with_checkbox() { - let doc = parse("- [X] done\n"); - let BlockNode::List(list) = &doc.children[0] else { - panic!("expected list"); - }; - let item = &list.items[0]; - assert_eq!(item.checkbox, Some(CheckboxState::Done)); - assert!(matches!(&item.children[0], InlineNode::Text(t) if t.content == "done")); -} - -// ===== Blockquotes ===== - -#[test] -fn angle_blockquote_two_lines() { - let doc = parse("> first\n> second\n"); - let BlockNode::Blockquote(bq) = &doc.children[0] else { - panic!("expected blockquote"); - }; - assert_eq!(bq.children.len(), 2); - for (i, expected) in ["first", "second"].iter().enumerate() { - let BlockNode::Paragraph(p) = &bq.children[i] else { - panic!("blockquote child should be a paragraph"); - }; - assert!(matches!(&p.children[0], InlineNode::Text(t) if t.content == *expected)); - } -} - -#[test] -fn indent_blockquote() { - let doc = parse(" quoted\n"); - let BlockNode::Blockquote(bq) = &doc.children[0] else { - panic!("expected blockquote"); - }; - assert_eq!(bq.children.len(), 1); -} - -// ===== Tables ===== - -#[test] -fn simple_table_row() { - let doc = parse("| a | b |\n"); - let BlockNode::Table(t) = &doc.children[0] else { - panic!("expected table"); - }; - assert_eq!(t.rows.len(), 1); - let row = &t.rows[0]; - assert_eq!(row.cells.len(), 2); -} - -#[test] -fn table_with_header_separator() { - let doc = parse("| a | b |\n|---|---|\n| 1 | 2 |\n"); - let BlockNode::Table(t) = &doc.children[0] else { - panic!("expected table"); - }; - assert!(t.has_header); - assert!(t.rows[0].is_header); - assert!(!t.rows[1].is_header); -} - -#[test] -fn table_col_and_row_span_cells() { - let doc = parse("| a | > | \\/ |\n"); - let BlockNode::Table(t) = &doc.children[0] else { - panic!("expected table"); - }; - let cells = &t.rows[0].cells; - assert!(!cells[0].col_span && !cells[0].row_span); - assert!(cells[1].col_span); - assert!(cells[2].row_span); -} - -// ===== Definition list ===== - -#[test] -fn definition_term_with_inline_definition() { - let doc = parse("Term:: Definition\n"); - let BlockNode::DefinitionList(dl) = &doc.children[0] else { - panic!("expected definition list"); - }; - assert_eq!(dl.items.len(), 1); - let item = &dl.items[0]; - let term = item.term.as_ref().expect("term"); - assert!(matches!(&term[0], InlineNode::Text(t) if t.content == "Term")); - assert_eq!(item.definitions.len(), 1); - let def = &item.definitions[0]; - assert!(matches!(&def[0], InlineNode::Text(t) if t.content == "Definition")); -} - -#[test] -fn multiple_definition_items() { - let doc = parse("A:: 1\nB:: 2\n"); - let BlockNode::DefinitionList(dl) = &doc.children[0] else { - panic!("expected definition list"); - }; - assert_eq!(dl.items.len(), 2); -} - -// ===== Preformatted / Math ===== - -#[test] -fn preformatted_block_captures_content_and_language() { - let doc = parse("{{{rust\nfn main() {}\n}}}\n"); - let BlockNode::Preformatted(p) = &doc.children[0] else { - panic!("expected preformatted"); - }; - assert_eq!(p.content, "fn main() {}"); - assert_eq!(p.language.as_deref(), Some("rust")); -} - -#[test] -fn math_block_captures_content_and_environment() { - let doc = parse("{{$%align%\nx = 1\n}}$\n"); - let BlockNode::MathBlock(m) = &doc.children[0] else { - panic!("expected math block"); - }; - assert_eq!(m.content, "x = 1"); - assert_eq!(m.environment.as_deref(), Some("align")); -} - -// ===== Comments ===== - -#[test] -fn single_line_comment_block() { - let doc = parse("%% hidden\n"); - let BlockNode::Comment(c) = &doc.children[0] else { - panic!("expected comment"); - }; - assert_eq!(c.content, " hidden"); -} - -#[test] -fn multi_line_comment_block() { - let doc = parse("%%+ a\nb\n+%%\n"); - let BlockNode::Comment(c) = &doc.children[0] else { - panic!("expected comment"); - }; - assert_eq!(c.content, " a\nb"); -} - -// ===== Inline ===== - -#[test] -fn inline_bold_italic_strike_super_sub_code_math() { - let doc = parse("*b* _i_ ~~s~~ ^sup^ ,,sub,, `c` $m$\n"); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - let kinds: Vec<&str> = p - .children - .iter() - .map(|n| match n { - InlineNode::Bold(_) => "Bold", - InlineNode::Italic(_) => "Italic", - InlineNode::Strikethrough(_) => "Strike", - InlineNode::Superscript(_) => "Sup", - InlineNode::Subscript(_) => "Sub", - InlineNode::Code(_) => "Code", - InlineNode::MathInline(_) => "Math", - InlineNode::Text(_) => "Text", - _ => "Other", - }) - .collect(); - assert!(kinds.contains(&"Bold")); - assert!(kinds.contains(&"Italic")); - assert!(kinds.contains(&"Strike")); - assert!(kinds.contains(&"Sup")); - assert!(kinds.contains(&"Sub")); - assert!(kinds.contains(&"Code")); - assert!(kinds.contains(&"Math")); -} - -#[test] -fn unmatched_bold_falls_back_to_text() { - let doc = parse("2 * 3 = 6\n"); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - // No Bold node should appear; the orphan `*` becomes literal text. - assert!(!p.children.iter().any(|n| matches!(n, InlineNode::Bold(_)))); -} - -#[test] -fn nested_italic_around_bold() { - let doc = parse("_*x*_\n"); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - let InlineNode::Italic(it) = &p.children[0] else { - panic!("expected italic outer"); - }; - let InlineNode::Bold(b) = &it.children[0] else { - panic!("expected bold inside italic"); - }; - assert!(matches!(&b.children[0], InlineNode::Text(t) if t.content == "x")); -} - -#[test] -fn keywords_become_keyword_nodes() { - let cases = [ - ("TODO", Keyword::Todo), - ("DONE", Keyword::Done), - ("STARTED", Keyword::Started), - ("FIXME", Keyword::Fixme), - ("FIXED", Keyword::Fixed), - ("XXX", Keyword::Xxx), - ("STOPPED", Keyword::Stopped), - ]; - for (src, expected) in cases { - let doc = parse(&format!("{src} stuff\n")); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - let InlineNode::Keyword(k) = &p.children[0] else { - panic!("expected keyword for {src}"); - }; - assert_eq!(k.keyword, expected); - } -} - -#[test] -fn raw_url_inline() { - let doc = parse("see https://example.com here\n"); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - assert!(p - .children - .iter() - .any(|n| matches!(n, InlineNode::RawUrl(u) if u.url == "https://example.com"))); -} - -// ===== Wikilinks / external links / transclusions ===== - -#[test] -fn bare_wikilink() { - let doc = parse("[[Page]]\n"); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - let InlineNode::WikiLink(w) = &p.children[0] else { - panic!("expected wikilink"); - }; - assert_eq!(w.target.kind, LinkKind::Wiki); - assert_eq!(w.target.path.as_deref(), Some("Page")); - assert!(w.description.is_none()); -} - -#[test] -fn wikilink_with_anchor() { - let doc = parse("[[Page#Section]]\n"); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - let InlineNode::WikiLink(w) = &p.children[0] else { - panic!("expected wikilink"); - }; - assert_eq!(w.target.path.as_deref(), Some("Page")); - assert_eq!(w.target.anchor.as_deref(), Some("Section")); -} - -#[test] -fn anchor_only_wikilink() { - let doc = parse("[[#Section]]\n"); - let InlineNode::WikiLink(w) = &if let BlockNode::Paragraph(p) = &doc.children[0] { - p - } else { - panic!() - } - .children[0] else { - panic!("expected wikilink"); - }; - assert_eq!(w.target.kind, LinkKind::AnchorOnly); - assert_eq!(w.target.anchor.as_deref(), Some("Section")); -} - -#[test] -fn root_relative_and_filesystem_absolute_wikilinks() { - let doc = parse("[[/Page]] [[//etc/hosts]]\n"); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - let mut links = p.children.iter().filter_map(|n| match n { - InlineNode::WikiLink(w) => Some(w), - _ => None, - }); - let root = links.next().expect("root-relative"); - assert_eq!(root.target.kind, LinkKind::Wiki); - assert!(root.target.is_absolute); - assert_eq!(root.target.path.as_deref(), Some("Page")); - - let local = links.next().expect("filesystem-absolute"); - assert_eq!(local.target.kind, LinkKind::Local); - assert!(local.target.is_absolute); - assert_eq!(local.target.path.as_deref(), Some("etc/hosts")); -} - -#[test] -fn directory_wikilink() { - let doc = parse("[[dir/]]\n"); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - let InlineNode::WikiLink(w) = &p.children[0] else { - panic!("expected wikilink"); - }; - assert!(w.target.is_directory); - assert_eq!(w.target.path.as_deref(), Some("dir")); -} - -#[test] -fn interwiki_links_numbered_and_named() { - let doc = parse("[[wiki1:Page]] [[wn.Notes:Page]]\n"); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - let mut links = p.children.iter().filter_map(|n| match n { - InlineNode::WikiLink(w) => Some(w), - _ => None, - }); - let numbered = links.next().expect("numbered"); - assert_eq!(numbered.target.kind, LinkKind::Interwiki); - assert_eq!(numbered.target.wiki_index, Some(1)); - assert_eq!(numbered.target.path.as_deref(), Some("Page")); - - let named = links.next().expect("named"); - assert_eq!(named.target.kind, LinkKind::Interwiki); - assert_eq!(named.target.wiki_name.as_deref(), Some("Notes")); - assert_eq!(named.target.path.as_deref(), Some("Page")); -} - -#[test] -fn diary_and_file_and_local_kinds() { - for (src, expected) in [ - ("[[diary:2026-05-10]]", LinkKind::Diary), - ("[[file:/tmp/note.txt]]", LinkKind::File), - ("[[local:rel/path]]", LinkKind::Local), - ] { - let doc = parse(&format!("{src}\n")); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - let InlineNode::WikiLink(w) = &p.children[0] else { - panic!("expected wikilink for {src}"); - }; - assert_eq!(w.target.kind, expected, "src {src}"); - } -} - -#[test] -fn url_inside_brackets_is_external_link() { - let doc = parse("[[https://example.com|docs]]\n"); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - let InlineNode::ExternalLink(e) = &p.children[0] else { - panic!("expected external link"); - }; - assert_eq!(e.url, "https://example.com"); - let desc = e.description.as_ref().expect("description"); - assert!(matches!(&desc[0], InlineNode::Text(t) if t.content == "docs")); -} - -#[test] -fn transclusion_with_alt() { - let doc = parse("{{img.png|alt text}}\n"); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - let InlineNode::Transclusion(t) = &p.children[0] else { - panic!("expected transclusion"); - }; - assert_eq!(t.url, "img.png"); - assert_eq!(t.alt.as_deref(), Some("alt text")); -} - -#[test] -fn transclusion_with_alt_and_attrs() { - let doc = parse("{{img.png|alt|class=hi|width=200}}\n"); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - let InlineNode::Transclusion(t) = &p.children[0] else { - panic!("expected transclusion"); - }; - assert_eq!(t.url, "img.png"); - assert_eq!(t.alt.as_deref(), Some("alt")); - assert_eq!(t.attrs.get("class").map(String::as_str), Some("hi")); - assert_eq!(t.attrs.get("width").map(String::as_str), Some("200")); -} - -// ===== Resilience ===== - -#[test] -fn unterminated_wikilink_falls_back_to_text() { - let doc = parse("[[Unterminated\n"); - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected paragraph"); - }; - // First child should be literal "[[" text. - assert!(matches!(&p.children[0], InlineNode::Text(t) if t.content == "[[")); -} - -// ===== End-to-end through the registry ===== - -#[test] -fn vimwiki_syntax_registers_and_dispatches() { - use nuwiki_core::syntax::SyntaxRegistry; - - let mut reg = SyntaxRegistry::new(); - reg.register(VimwikiSyntax::new()); - - let plugin = reg.detect_from_extension(".wiki").expect("plugin by ext"); - assert_eq!(plugin.id(), "vimwiki"); - - let doc = plugin.parse("= Hello =\n"); - assert!(matches!(doc.children[0], BlockNode::Heading(_))); -} diff --git a/crates/nuwiki-core/tests/syntax.rs b/crates/nuwiki-core/tests/syntax.rs deleted file mode 100644 index 4f0ef14..0000000 --- a/crates/nuwiki-core/tests/syntax.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! Integration tests for the syntax plugin interface. - -use nuwiki_core::ast::{ - inline::InlineNode, BlockNode, DocumentNode, ParagraphNode, Span, TextNode, -}; -use nuwiki_core::syntax::{Lexer, Parser, SyntaxPlugin, SyntaxRegistry, TokenStream}; - -// --- A minimal mock plugin: tokenises the input on whitespace, then wraps -// the joined text in a single Paragraph. Just enough surface to exercise -// every trait + the registry. - -#[derive(Debug, Clone, PartialEq, Eq)] -struct MockToken(String); - -struct MockLexer; -impl Lexer for MockLexer { - type Token = MockToken; - - fn lex(&self, text: &str) -> TokenStream { - TokenStream::from_vec( - text.split_whitespace() - .map(|w| MockToken(w.to_owned())) - .collect(), - ) - } -} - -struct MockParser; -impl Parser for MockParser { - type Token = MockToken; - - fn parse(&self, tokens: TokenStream) -> DocumentNode { - let joined = tokens - .iter() - .map(|t| t.0.as_str()) - .collect::>() - .join(" "); - let paragraph = BlockNode::Paragraph(ParagraphNode { - span: Span::default(), - children: vec![InlineNode::Text(TextNode { - span: Span::default(), - content: joined, - })], - }); - DocumentNode { - span: Span::default(), - children: vec![paragraph], - metadata: Default::default(), - } - } -} - -struct MockPlugin; -impl SyntaxPlugin for MockPlugin { - fn id(&self) -> &str { - "mock" - } - fn display_name(&self) -> &str { - "Mock Syntax" - } - fn file_extensions(&self) -> &[&str] { - &[".mock", ".mk"] - } - fn parse(&self, text: &str) -> DocumentNode { - MockParser.parse(MockLexer.lex(text)) - } -} - -#[test] -fn token_stream_roundtrip() { - let mut s = TokenStream::::new(); - assert!(s.is_empty()); - s.push(1); - s.push(2); - s.push(3); - assert_eq!(s.len(), 3); - assert_eq!(s.as_slice(), &[1, 2, 3]); - assert_eq!(s.iter().sum::(), 6); - assert_eq!((&s).into_iter().sum::(), 6); - assert_eq!(s.into_vec(), vec![1, 2, 3]); - - let from_vec: TokenStream<&str> = vec!["a", "b"].into(); - assert_eq!(from_vec.len(), 2); - let collected: Vec<&str> = from_vec.into_iter().collect(); - assert_eq!(collected, vec!["a", "b"]); -} - -#[test] -fn lexer_and_parser_chain_through_plugin() { - let plugin = MockPlugin; - let doc = plugin.parse(" hello world "); - - let BlockNode::Paragraph(p) = &doc.children[0] else { - panic!("expected a paragraph, got {:?}", doc.children[0]); - }; - let InlineNode::Text(t) = &p.children[0] else { - panic!("expected a text node"); - }; - assert_eq!(t.content, "hello world"); -} - -#[test] -fn registry_lookup_by_id_and_extension() { - let mut registry = SyntaxRegistry::new(); - assert!(registry.is_empty()); - - registry.register(MockPlugin); - assert_eq!(registry.len(), 1); - - let by_id = registry - .get("mock") - .expect("plugin should be findable by id"); - assert_eq!(by_id.display_name(), "Mock Syntax"); - - let by_ext = registry - .detect_from_extension(".mk") - .expect("plugin should be findable by secondary extension"); - assert_eq!(by_ext.id(), "mock"); - - assert!(registry.get("nonexistent").is_none()); - assert!(registry.detect_from_extension(".unknown").is_none()); - - // Iteration order matches registration order. - let ids: Vec<&str> = registry.iter().map(|p| p.id()).collect(); - assert_eq!(ids, vec!["mock"]); -} - -#[test] -fn registry_can_parse_through_trait_object() { - let mut registry = SyntaxRegistry::new(); - registry.register(MockPlugin); - - let plugin = registry.detect_from_extension(".mock").unwrap(); - let doc = plugin.parse("a b c"); - assert_eq!(doc.children.len(), 1); -} - -/// Compile-time check: SyntaxRegistry must be Send + Sync so the LSP server -/// can share it across handler tasks. -#[test] -fn registry_is_send_and_sync() { - fn assert_send_sync() {} - assert_send_sync::(); -} diff --git a/crates/nuwiki-core/tests/table_alignment.rs b/crates/nuwiki-core/tests/table_alignment.rs deleted file mode 100644 index 5bd6bf9..0000000 --- a/crates/nuwiki-core/tests/table_alignment.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Cluster 7 — Markdown-style alignment markers on the header -//! separator row (`|:--|--:|:--:|`) propagate to the AST and HTML. - -use nuwiki_core::ast::{BlockNode, TableAlign}; -use nuwiki_core::render::{HtmlRenderer, Renderer}; -use nuwiki_core::syntax::vimwiki::VimwikiSyntax; -use nuwiki_core::syntax::SyntaxPlugin; - -fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { - VimwikiSyntax::new().parse(src) -} - -#[test] -fn separator_row_with_no_anchors_yields_defaults() { - let doc = parse("| h1 | h2 |\n|----|----|\n| a | b |\n"); - let table = match &doc.children[0] { - BlockNode::Table(t) => t, - _ => panic!("expected table"), - }; - assert_eq!( - table.alignments, - vec![TableAlign::Default, TableAlign::Default] - ); -} - -#[test] -fn left_right_center_anchors_parsed() { - let doc = parse("| a | b | c |\n|:---|---:|:---:|\n| 1 | 2 | 3 |\n"); - let table = match &doc.children[0] { - BlockNode::Table(t) => t, - _ => panic!("expected table"), - }; - assert_eq!( - table.alignments, - vec![TableAlign::Left, TableAlign::Right, TableAlign::Center] - ); -} - -#[test] -fn html_renderer_emits_text_align_style() { - let doc = parse("| a | b | c |\n|:---|---:|:---:|\n| 1 | 2 | 3 |\n"); - let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); - assert!( - out.contains(r#"

              "#), - "got: {out}" - ); - assert!(out.contains(r#""#)); - assert!(out.contains(r#""#)); - assert!(out.contains(r#""#)); -} - -#[test] -fn render_without_alignment_omits_style_attr() { - let doc = parse("| a | b |\n|---|---|\n| 1 | 2 |\n"); - let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); - assert!(!out.contains("text-align")); -} diff --git a/crates/nuwiki-core/tests/tags.rs b/crates/nuwiki-core/tests/tags.rs deleted file mode 100644 index 83afb16..0000000 --- a/crates/nuwiki-core/tests/tags.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! Tag tests: lexer + parser + renderer behaviour on -//! `:tag:` lines, plus the scope-detection rules. - -use nuwiki_core::ast::{BlockNode, TagScope}; -use nuwiki_core::render::{HtmlRenderer, Renderer}; -use nuwiki_core::syntax::vimwiki::{VimwikiLexer, VimwikiSyntax, VimwikiTokenKind}; -use nuwiki_core::syntax::Lexer; -use nuwiki_core::syntax::SyntaxPlugin; - -fn lex(src: &str) -> Vec { - VimwikiLexer::new() - .lex(src) - .into_iter() - .map(|t| t.kind) - .collect() -} - -fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { - VimwikiSyntax::new().parse(src) -} - -fn render(src: &str) -> String { - let doc = parse(src); - HtmlRenderer::new().render_to_string(&doc).unwrap() -} - -// ===== Lexer ===== - -#[test] -fn lexer_emits_tag_for_single_tag_line() { - let toks = lex(":todo:\n"); - assert!(toks - .iter() - .any(|t| matches!(t, VimwikiTokenKind::Tag(v) if v == &vec!["todo".to_string()]))); -} - -#[test] -fn lexer_emits_tag_for_multiple_chained_tags() { - let toks = lex(":one:two:three:\n"); - let names = toks - .iter() - .find_map(|t| match t { - VimwikiTokenKind::Tag(v) => Some(v.clone()), - _ => None, - }) - .expect("tag token"); - assert_eq!(names, vec!["one", "two", "three"]); -} - -#[test] -fn lexer_rejects_empty_segment() { - // `::tag::` — empty leading segment between the doubled colons. - let toks = lex("::tag::\n"); - assert!(!toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_)))); -} - -#[test] -fn lexer_rejects_tag_with_whitespace() { - let toks = lex(":one tag:other:\n"); - assert!(!toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_)))); -} - -#[test] -fn lexer_does_not_collide_with_definition_term() { - // `Term::` is a definition-term marker (text *before* the `::`); a - // tag line has nothing before its leading `:`. The two patterns must - // not steal each other's lines. - let toks = lex("Term:: Definition\n:foo:\n"); - assert!(toks - .iter() - .any(|t| matches!(t, VimwikiTokenKind::DefinitionTermMarker))); - assert!(toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_)))); -} - -#[test] -fn lexer_accepts_indented_tag_line() { - let toks = lex(" :indented:\n"); - assert!(toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_)))); -} - -// ===== Parser scope rules ===== - -#[test] -fn scope_is_file_when_tag_is_on_line_0_or_1() { - let doc = parse(":todo:other:\n= Heading =\n"); - let BlockNode::Tag(t) = &doc.children[0] else { - panic!( - "expected first block to be a Tag, got {:?}", - doc.children[0] - ); - }; - assert_eq!(t.scope, TagScope::File); - assert_eq!(t.tags, vec!["todo", "other"]); - assert_eq!(doc.metadata.tags, vec!["todo", "other"]); -} - -#[test] -fn scope_is_file_for_tag_on_line_1() { - let doc = parse("a paragraph\n:todo:\n"); - // Tag is on line 1, so still file-level. - let tag = doc - .children - .iter() - .find_map(|b| match b { - BlockNode::Tag(t) => Some(t), - _ => None, - }) - .expect("tag block"); - assert_eq!(tag.scope, TagScope::File); - assert!(doc.metadata.tags.contains(&"todo".to_string())); -} - -#[test] -fn scope_is_heading_when_tag_is_one_or_two_lines_below_heading() { - // Heading at line 0; tag at line 1 (one below) → Heading(0). - let doc = parse("= H1 =\n:hot:\n"); - let tag = doc - .children - .iter() - .find_map(|b| match b { - BlockNode::Tag(t) => Some(t), - _ => None, - }) - .expect("tag block"); - // Tag at line 1 is *also* line ≤ 1 → file rule wins (matches vimwiki: - // file rule applies to lines 0-1 regardless of preceding heading). - assert_eq!(tag.scope, TagScope::File); -} - -#[test] -fn scope_is_heading_for_tag_two_lines_below_a_deeper_heading() { - // Heading at line 3; tag at line 4 (one below). - let src = "first\n\n\n= Section =\n:scoped:\n"; - let doc = parse(src); - let tag = doc - .children - .iter() - .find_map(|b| match b { - BlockNode::Tag(t) => Some(t), - _ => None, - }) - .expect("tag block"); - // Heading is the only one seen; index 0. - assert_eq!(tag.scope, TagScope::Heading(0)); - // Heading-scope tags do NOT contribute to file-level metadata. - assert!(doc.metadata.tags.is_empty()); -} - -#[test] -fn scope_is_standalone_for_distant_tag() { - // Heading at line 3; tag at line 6 → 3 lines below → out of the - // heading window (>2), so standalone. - let src = "x\n\n\n= H =\n\n\n:wandering:\n"; - let doc = parse(src); - let tag = doc - .children - .iter() - .find_map(|b| match b { - BlockNode::Tag(t) => Some(t), - _ => None, - }) - .expect("tag block"); - assert_eq!(tag.scope, TagScope::Standalone); -} - -#[test] -fn scope_is_standalone_when_no_heading_precedes() { - // No heading at all and not on lines 0-1. - let doc = parse("a\nb\nc\n:lonely:\n"); - let tag = doc - .children - .iter() - .find_map(|b| match b { - BlockNode::Tag(t) => Some(t), - _ => None, - }) - .expect("tag block"); - assert_eq!(tag.scope, TagScope::Standalone); -} - -#[test] -fn file_tags_are_deduped_in_metadata() { - let doc = parse(":a:b:\n:b:c:\n"); - assert_eq!(doc.metadata.tags, vec!["a", "b", "c"]); -} - -// ===== Renderer ===== - -#[test] -fn renderer_emits_div_with_tag_spans() { - let html = render(":todo:done:\n"); - assert!(html.contains("
              ")); - // Bare tag name as the id (vimwiki parity), so `[[Page#todo]]` resolves. - assert!(html.contains("todo")); - assert!(html.contains("done")); -} - -#[test] -fn renderer_escapes_html_inside_tag_names() { - // Tags shouldn't contain `<`/`>` per the lexer rule (whitespace - // forbidden, but `<` isn't), so this checks the renderer's escaping - // fires anyway as defence-in-depth. - let html = render(":a nuwiki_core::ast::DocumentNode { - VimwikiSyntax::new().parse(src) -} - -fn first_transclusion(doc: &nuwiki_core::ast::DocumentNode) -> &nuwiki_core::ast::TransclusionNode { - let para = match &doc.children[0] { - BlockNode::Paragraph(p) => p, - _ => panic!("expected paragraph"), - }; - for child in ¶.children { - if let InlineNode::Transclusion(t) = child { - return t; - } - } - panic!("no transclusion in paragraph"); -} - -#[test] -fn transclusion_attrs_strip_double_quotes() { - let doc = parse(r#"{{cat.png|cat|style="border: 1px"}}"#); - let t = first_transclusion(&doc); - assert_eq!(t.url, "cat.png"); - assert_eq!(t.alt.as_deref(), Some("cat")); - assert_eq!( - t.attrs.get("style").map(String::as_str), - Some("border: 1px") - ); -} - -#[test] -fn transclusion_attrs_strip_single_quotes() { - let doc = parse("{{cat.png|cat|class='thumb'}}"); - let t = first_transclusion(&doc); - assert_eq!(t.attrs.get("class").map(String::as_str), Some("thumb")); -} - -#[test] -fn transclusion_attrs_leave_unquoted_values_alone() { - let doc = parse("{{cat.png|cat|width=200}}"); - let t = first_transclusion(&doc); - assert_eq!(t.attrs.get("width").map(String::as_str), Some("200")); -} - -#[test] -fn transclusion_renders_clean_html_with_quoted_attr() { - let doc = parse(r#"{{cat.png|cat|style="border: 1px"}}"#); - let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); - assert!(out.contains(r#"style="border: 1px""#), "got: {out}"); - assert!(!out.contains(""")); -} diff --git a/crates/nuwiki-core/tests/visitor.rs b/crates/nuwiki-core/tests/visitor.rs deleted file mode 100644 index 071c20b..0000000 --- a/crates/nuwiki-core/tests/visitor.rs +++ /dev/null @@ -1,215 +0,0 @@ -//! Smoke test for the AST visitor: build a small document by hand and -//! confirm a Visitor descends into every nested node exactly once. - -use std::collections::HashMap; - -use nuwiki_core::ast::{ - BlockNode, BoldNode, DocumentNode, ExternalLinkNode, HeadingNode, InlineNode, ListItemNode, - ListNode, ListSymbol, ParagraphNode, Span, TableCellNode, TableNode, TableRowNode, TextNode, - TransclusionNode, Visitor, -}; - -#[derive(Default)] -struct Counter { - headings: usize, - paragraphs: usize, - text: usize, - bold: usize, - list_items: usize, - table_cells: usize, - external_links: usize, - transclusions: usize, - blocks: usize, - inlines: usize, -} - -impl Visitor for Counter { - fn visit_block(&mut self, node: &BlockNode) { - self.blocks += 1; - nuwiki_core::ast::walk_block(self, node); - } - fn visit_inline(&mut self, node: &InlineNode) { - self.inlines += 1; - nuwiki_core::ast::walk_inline(self, node); - } - fn visit_heading(&mut self, node: &HeadingNode) { - self.headings += 1; - for child in &node.children { - self.visit_inline(child); - } - } - fn visit_paragraph(&mut self, node: &ParagraphNode) { - self.paragraphs += 1; - for child in &node.children { - self.visit_inline(child); - } - } - fn visit_text(&mut self, _: &TextNode) { - self.text += 1; - } - fn visit_bold(&mut self, node: &BoldNode) { - self.bold += 1; - for child in &node.children { - self.visit_inline(child); - } - } - fn visit_list_item(&mut self, node: &ListItemNode) { - self.list_items += 1; - nuwiki_core::ast::walk_list_item(self, node); - } - fn visit_table_cell(&mut self, node: &TableCellNode) { - self.table_cells += 1; - for child in &node.children { - self.visit_inline(child); - } - } - fn visit_external_link(&mut self, node: &ExternalLinkNode) { - self.external_links += 1; - if let Some(desc) = &node.description { - for child in desc { - self.visit_inline(child); - } - } - } - fn visit_transclusion(&mut self, _: &TransclusionNode) { - self.transclusions += 1; - } -} - -fn text(s: &str) -> InlineNode { - InlineNode::Text(TextNode { - span: Span::default(), - content: s.into(), - }) -} - -#[test] -fn visitor_descends_into_every_node() { - // = Title with *bold* word = - let heading = BlockNode::Heading(HeadingNode { - span: Span::default(), - level: 1, - centered: false, - children: vec![ - text("Title with "), - InlineNode::Bold(BoldNode { - span: Span::default(), - children: vec![text("bold")], - }), - text(" word"), - ], - }); - - // A paragraph containing an external link with two text children in its - // description, and a transclusion sibling. - let paragraph = BlockNode::Paragraph(ParagraphNode { - span: Span::default(), - children: vec![ - InlineNode::ExternalLink(ExternalLinkNode { - span: Span::default(), - url: "https://example.com".into(), - description: Some(vec![text("see "), text("docs")]), - }), - InlineNode::Transclusion(TransclusionNode { - span: Span::default(), - url: "image.png".into(), - alt: None, - attrs: HashMap::new(), - }), - ], - }); - - // - one - // - two - let list = BlockNode::List(ListNode { - span: Span::default(), - ordered: false, - symbol: ListSymbol::Dash, - items: vec![ - ListItemNode { - span: Span::default(), - symbol: ListSymbol::Dash, - level: 0, - checkbox: None, - children: vec![text("one")], - sublist: None, - }, - ListItemNode { - span: Span::default(), - symbol: ListSymbol::Dash, - level: 0, - checkbox: None, - children: vec![text("two")], - sublist: None, - }, - ], - }); - - // | a | b | - let table = BlockNode::Table(TableNode { - span: Span::default(), - has_header: false, - alignments: Vec::new(), - rows: vec![TableRowNode { - span: Span::default(), - is_header: false, - cells: vec![ - TableCellNode { - span: Span::default(), - children: vec![text("a")], - col_span: false, - row_span: false, - }, - TableCellNode { - span: Span::default(), - children: vec![text("b")], - col_span: false, - row_span: false, - }, - ], - }], - }); - - let doc = DocumentNode { - span: Span::default(), - metadata: Default::default(), - children: vec![heading, paragraph, list, table], - }; - - let mut counter = Counter::default(); - counter.visit_document(&doc); - - // Block-level: 4 top-level + 2 list items aren't blocks - assert_eq!(counter.blocks, 4, "top-level blocks"); - assert_eq!(counter.headings, 1); - assert_eq!(counter.paragraphs, 1); - assert_eq!(counter.list_items, 2); - // 1 cell row with 2 cells - assert_eq!(counter.table_cells, 2); - - // Inlines: - // heading: 3 ("Title with ", Bold, " word") + 1 (bold child) = 4 - // paragraph: 2 (ExternalLink, Transclusion) + 2 (link desc) = 4 - // list items: 2 (one each) - // table cells: 2 (one each) - // total: 12 - assert_eq!(counter.inlines, 12); - // Text leaves: heading 3, link description 2, list items 2, table cells 2 = 9 - assert_eq!(counter.text, 9, "Text leaves"); - assert_eq!(counter.bold, 1); - assert_eq!(counter.external_links, 1); - assert_eq!(counter.transclusions, 1); -} - -#[test] -fn document_default_is_empty() { - let doc = DocumentNode::default(); - assert!(doc.children.is_empty()); - assert_eq!(doc.metadata.title, None); - assert!(!doc.metadata.nohtml); - - let mut counter = Counter::default(); - counter.visit_document(&doc); - assert_eq!(counter.blocks, 0); - assert_eq!(counter.inlines, 0); -} diff --git a/crates/nuwiki-ls/Cargo.toml b/crates/nuwiki-ls/Cargo.toml deleted file mode 100644 index 9ee85a7..0000000 --- a/crates/nuwiki-ls/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "nuwiki-ls" -description = "nuwiki language server binary — speaks LSP over stdio." -version.workspace = true -edition.workspace = true -rust-version.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true - -[lints] -workspace = true - -[[bin]] -name = "nuwiki-ls" -path = "src/main.rs" - -[dependencies] -nuwiki-lsp = { path = "../nuwiki-lsp", version = "0.4.2" } -tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] } diff --git a/crates/nuwiki-ls/src/main.rs b/crates/nuwiki-ls/src/main.rs deleted file mode 100644 index 6055399..0000000 --- a/crates/nuwiki-ls/src/main.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! `nuwiki-ls` — language-server binary. Speaks LSP over stdio. - -#[tokio::main] -async fn main() -> std::io::Result<()> { - nuwiki_lsp::run_stdio().await -} diff --git a/crates/nuwiki-lsp/Cargo.toml b/crates/nuwiki-lsp/Cargo.toml deleted file mode 100644 index fc35afa..0000000 --- a/crates/nuwiki-lsp/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "nuwiki-lsp" -description = "LSP protocol bridge for nuwiki, built on top of nuwiki-core." -version.workspace = true -edition.workspace = true -rust-version.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true - -[lints] -workspace = true - -[dependencies] -nuwiki-core = { path = "../nuwiki-core", version = "0.4.2" } -tower-lsp = "0.20" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync", "fs"] } -dashmap = "6" -async-trait = "0.1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs deleted file mode 100644 index c5a3eab..0000000 --- a/crates/nuwiki-lsp/src/commands.rs +++ /dev/null @@ -1,4030 +0,0 @@ -//! `workspace/executeCommand` dispatcher. -//! -//! The router handles `nuwiki.file.delete`, the cheap surgical edits — -//! checkbox toggle/cycle/reject, heading promote/demote, "next task" -//! navigation — and the generation + workspace-query commands: -//! `toc.generate`, `links.generate`, `workspace.checkLinks`, -//! `workspace.findOrphans`. -//! -//! Every command resolves to either a `WorkspaceEdit` (the server then -//! calls `apply_edit`) or a JSON `Value` (returned to the client) — see -//! `CommandOutcome` below. `ops::*` are the pure functions the dispatcher -//! wraps; they're `pub` so tests and future tooling can drive them -//! without spinning up a Backend. - -use serde::Deserialize; -use serde_json::Value; -use std::sync::atomic::Ordering; -use tower_lsp::lsp_types::{Position as LspPosition, Url, WorkspaceEdit}; - -use crate::edits::{op_delete, WorkspaceEditBuilder}; -use crate::nav; -use crate::Backend; - -/// Result of a single executeCommand dispatch. -pub enum CommandOutcome { - /// Push to the client via `workspace/applyEdit`. - Edit(WorkspaceEdit), - /// Return verbatim as the executeCommand response. - Value(Value), -} - -/// All commands the server advertises in `ExecuteCommandOptions.commands`. -/// Keep in lock-step with the match arms in `execute`. -pub const COMMANDS: &[&str] = &[ - "nuwiki.file.delete", - "nuwiki.list.toggleCheckbox", - "nuwiki.list.cycleCheckbox", - "nuwiki.list.rejectCheckbox", - "nuwiki.list.nextTask", - "nuwiki.heading.addLevel", - "nuwiki.heading.removeLevel", - "nuwiki.toc.generate", - "nuwiki.links.generate", - "nuwiki.links.generateForPath", - "nuwiki.workspace.checkLinks", - "nuwiki.workspace.findOrphans", - "nuwiki.diary.openToday", - "nuwiki.diary.openYesterday", - "nuwiki.diary.openTomorrow", - "nuwiki.diary.openIndex", - "nuwiki.diary.generateIndex", - "nuwiki.diary.next", - "nuwiki.diary.prev", - "nuwiki.diary.listEntries", - "nuwiki.diary.openForDate", - "nuwiki.tags.search", - "nuwiki.tags.generateLinks", - "nuwiki.tags.rebuild", - "nuwiki.export.currentToHtml", - "nuwiki.export.allToHtml", - "nuwiki.export.allToHtmlForce", - "nuwiki.export.browse", - "nuwiki.export.rss", - "nuwiki.wiki.listAll", - "nuwiki.wiki.select", - "nuwiki.wiki.openIndex", - "nuwiki.wiki.tabOpenIndex", - "nuwiki.wiki.gotoPage", - "nuwiki.link.pasteWikilink", - "nuwiki.link.pasteUrl", - "nuwiki.list.removeDone", - "nuwiki.list.removeCheckbox", - "nuwiki.list.renumber", - "nuwiki.list.changeSymbol", - "nuwiki.list.changeLevel", - "nuwiki.link.catUrl", - "nuwiki.table.insert", - "nuwiki.table.align", - "nuwiki.table.moveColumn", -]; - -pub(crate) async fn execute( - backend: &Backend, - command: &str, - args: Vec, -) -> Result, String> { - match command { - "nuwiki.file.delete" => file_delete(args).map(|opt| opt.map(CommandOutcome::Edit)), - "nuwiki.list.toggleCheckbox" => { - Ok(list_checkbox(backend, args, ops::toggle_state)?.map(CommandOutcome::Edit)) - } - "nuwiki.list.cycleCheckbox" => { - let reverse = args - .first() - .and_then(|v| v.get("reverse")) - .and_then(Value::as_bool) - .unwrap_or(false); - let mutate = if reverse { - ops::cycle_state_back - } else { - ops::cycle_state - }; - Ok(list_checkbox(backend, args, mutate)?.map(CommandOutcome::Edit)) - } - "nuwiki.list.rejectCheckbox" => { - Ok(list_checkbox(backend, args, ops::reject_state)?.map(CommandOutcome::Edit)) - } - "nuwiki.list.nextTask" => { - list_next_task(backend, args).map(|opt| opt.map(CommandOutcome::Value)) - } - "nuwiki.heading.addLevel" => { - Ok(heading_change_level(backend, args, 1)?.map(CommandOutcome::Edit)) - } - "nuwiki.heading.removeLevel" => { - Ok(heading_change_level(backend, args, -1)?.map(CommandOutcome::Edit)) - } - "nuwiki.toc.generate" => toc_generate(backend, args).map(|o| o.map(CommandOutcome::Edit)), - "nuwiki.links.generate" => { - links_generate(backend, args).map(|o| o.map(CommandOutcome::Edit)) - } - "nuwiki.links.generateForPath" => { - links_generate_for_path(backend, args).map(|o| o.map(CommandOutcome::Edit)) - } - "nuwiki.workspace.checkLinks" => { - workspace_check_links(backend, args).map(|o| o.map(CommandOutcome::Value)) - } - "nuwiki.workspace.findOrphans" => { - workspace_find_orphans(backend, args).map(|o| o.map(CommandOutcome::Value)) - } - "nuwiki.diary.openToday" => diary_open_relative(backend, args, RelativeDay::Today) - .map(|o| o.map(CommandOutcome::Value)), - "nuwiki.diary.openYesterday" => diary_open_relative(backend, args, RelativeDay::Yesterday) - .map(|o| o.map(CommandOutcome::Value)), - "nuwiki.diary.openTomorrow" => diary_open_relative(backend, args, RelativeDay::Tomorrow) - .map(|o| o.map(CommandOutcome::Value)), - "nuwiki.diary.openIndex" => { - diary_open_index(backend, args).map(|o| o.map(CommandOutcome::Value)) - } - "nuwiki.diary.generateIndex" => { - diary_generate_index(backend, args).map(|o| o.map(CommandOutcome::Edit)) - } - "nuwiki.diary.next" => { - diary_step(backend, args, true).map(|o| o.map(CommandOutcome::Value)) - } - "nuwiki.diary.prev" => { - diary_step(backend, args, false).map(|o| o.map(CommandOutcome::Value)) - } - "nuwiki.diary.listEntries" => { - diary_list(backend, args).map(|o| o.map(CommandOutcome::Value)) - } - "nuwiki.diary.openForDate" => { - diary_open_for_date(backend, args).map(|o| o.map(CommandOutcome::Value)) - } - "nuwiki.tags.search" => tags_search(backend, args).map(|o| o.map(CommandOutcome::Value)), - "nuwiki.tags.generateLinks" => { - tags_generate_links(backend, args).map(|o| o.map(CommandOutcome::Edit)) - } - "nuwiki.tags.rebuild" => tags_rebuild(backend, args).map(|o| o.map(CommandOutcome::Value)), - "nuwiki.export.currentToHtml" => { - export_current(backend, args, false).map(|o| o.map(CommandOutcome::Value)) - } - "nuwiki.export.allToHtml" => { - export_all(backend, args, false).map(|o| o.map(CommandOutcome::Value)) - } - "nuwiki.export.allToHtmlForce" => { - export_all(backend, args, true).map(|o| o.map(CommandOutcome::Value)) - } - "nuwiki.export.browse" => { - export_current(backend, args, true).map(|o| o.map(CommandOutcome::Value)) - } - "nuwiki.export.rss" => export_rss(backend, args).map(|o| o.map(CommandOutcome::Value)), - "nuwiki.wiki.listAll" => wiki_list_all(backend).map(|o| o.map(CommandOutcome::Value)), - "nuwiki.wiki.select" => wiki_list_all(backend).map(|o| o.map(CommandOutcome::Value)), - "nuwiki.wiki.openIndex" => { - wiki_open_index(backend, args, false).map(|o| o.map(CommandOutcome::Value)) - } - "nuwiki.wiki.tabOpenIndex" => { - wiki_open_index(backend, args, true).map(|o| o.map(CommandOutcome::Value)) - } - "nuwiki.wiki.gotoPage" => { - wiki_goto_page(backend, args).map(|o| o.map(CommandOutcome::Value)) - } - "nuwiki.link.pasteWikilink" => { - link_paste(backend, args, PasteKind::Wikilink).map(|o| o.map(CommandOutcome::Edit)) - } - "nuwiki.link.pasteUrl" => { - link_paste(backend, args, PasteKind::Url).map(|o| o.map(CommandOutcome::Edit)) - } - "nuwiki.list.removeDone" => { - list_remove_done(backend, args).map(|o| o.map(CommandOutcome::Edit)) - } - "nuwiki.list.removeCheckbox" => { - list_remove_checkbox(backend, args).map(|o| o.map(CommandOutcome::Edit)) - } - "nuwiki.link.catUrl" => link_cat_url(backend, args).map(|o| o.map(CommandOutcome::Value)), - "nuwiki.list.renumber" => list_renumber(backend, args).map(|o| o.map(CommandOutcome::Edit)), - "nuwiki.list.changeSymbol" => { - list_change_symbol(backend, args).map(|o| o.map(CommandOutcome::Edit)) - } - "nuwiki.list.changeLevel" => { - list_change_level(backend, args).map(|o| o.map(CommandOutcome::Edit)) - } - "nuwiki.table.insert" => table_insert(backend, args).map(|o| o.map(CommandOutcome::Edit)), - "nuwiki.table.align" => table_align(backend, args).map(|o| o.map(CommandOutcome::Edit)), - "nuwiki.table.moveColumn" => { - table_move_column(backend, args).map(|o| o.map(CommandOutcome::Edit)) - } - other => Err(format!("unknown nuwiki command: {other}")), - } -} - -#[derive(Deserialize)] -struct PosArgs { - uri: Url, - position: LspPosition, -} - -fn parse_pos(args: Vec) -> Result { - let raw = args - .into_iter() - .next() - .ok_or_else(|| "missing { uri, position } argument".to_string())?; - serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}")) -} - -fn file_delete(args: Vec) -> Result, String> { - #[derive(Deserialize)] - struct Args { - uri: Url, - } - let raw = args - .into_iter() - .next() - .ok_or_else(|| "nuwiki.file.delete: missing arguments".to_string())?; - let parsed: Args = serde_json::from_value(raw) - .map_err(|e| format!("nuwiki.file.delete: invalid args — {e}"))?; - let mut builder = WorkspaceEditBuilder::new(); - builder.file_op(op_delete(parsed.uri)); - Ok(Some(builder.build())) -} - -fn list_checkbox( - backend: &Backend, - args: Vec, - mutate: fn(&nuwiki_core::listsyms::ListSyms, &str) -> Option, -) -> Result, String> { - let p = parse_pos(args)?; - let doc = match backend.documents.get(&p.uri) { - Some(d) => d, - None => return Ok(None), - }; - let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8); - let wiki = backend.wiki_for_uri(&p.uri); - let syms = wiki - .as_ref() - .map(|w| w.config.list_syms()) - .unwrap_or_default(); - let propagate = wiki.map(|w| w.config.listsyms_propagate).unwrap_or(true); - Ok(ops::checkbox_edit( - &doc.text, &doc.ast, &p.uri, line, col, utf8, &syms, mutate, propagate, - )) -} - -fn list_next_task(backend: &Backend, args: Vec) -> Result, String> { - let p = parse_pos(args)?; - let doc = match backend.documents.get(&p.uri) { - Some(d) => d, - None => return Ok(None), - }; - let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8); - let Some(loc) = ops::next_task(&doc.ast, &p.uri, &doc.text, line, col, utf8) else { - return Ok(None); - }; - serde_json::to_value(loc) - .map(Some) - .map_err(|e| format!("nuwiki.list.nextTask: serialization failed — {e}")) -} - -fn heading_change_level( - backend: &Backend, - args: Vec, - delta: i32, -) -> Result, String> { - let p = parse_pos(args)?; - let doc = match backend.documents.get(&p.uri) { - Some(d) => d, - None => return Ok(None), - }; - let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8); - Ok(ops::heading_change_level( - &doc.text, &doc.ast, &p.uri, line, col, utf8, delta, - )) -} - -#[derive(Deserialize)] -struct UriArg { - uri: Url, -} - -fn parse_uri_arg(args: Vec) -> Result { - let raw = args - .into_iter() - .next() - .ok_or_else(|| "missing { uri } argument".to_string())?; - serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}")) -} - -/// Parse `{ uri, line? }` for the section generators (TOC / links / tags). The -/// optional 0-based `line` is the cursor line the client sends, where a fresh -/// section is inserted. -fn parse_uri_line_arg(args: Vec) -> Result<(Url, Option), String> { - #[derive(Deserialize)] - struct A { - uri: Url, - #[serde(default)] - line: Option, - } - let raw = args - .into_iter() - .next() - .ok_or_else(|| "missing { uri } argument".to_string())?; - let a: A = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?; - Ok((a.uri, a.line)) -} - -#[derive(Deserialize, Default)] -struct OptionalUriArg { - #[serde(default)] - uri: Option, - /// Optional wiki selector (vimwiki's diary `-count`): the client sends a - /// 0-indexed wiki number when the user gave a count. When present it wins - /// over `uri`-based resolution (see `resolve_diary_wiki`). - #[serde(default)] - wiki: Option, -} - -fn parse_optional_uri_arg(args: Vec) -> Result { - match args.into_iter().next() { - Some(v) => serde_json::from_value(v).map_err(|e| format!("invalid args: {e}")), - None => Ok(OptionalUriArg::default()), - } -} - -fn toc_generate(backend: &Backend, args: Vec) -> Result, String> { - let (uri, cursor_line) = parse_uri_line_arg(args)?; - let doc = match backend.documents.get(&uri) { - Some(d) => d, - None => return Ok(None), - }; - let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let wiki = backend.wiki_for_uri(&uri); - let (header, level) = wiki - .as_ref() - .map(|w| (w.config.toc_header.clone(), w.config.toc_header_level)) - .unwrap_or_else(|| (ops::TOC_HEADING.to_string(), 1)); - let margin = wiki - .as_ref() - .map(|w| w.config.list_margin.max(0) as usize) - .unwrap_or(0); - let link_format = wiki.as_ref().map(|w| w.config.toc_link_format).unwrap_or(0); - Ok(ops::toc_edit( - &doc.text, - &doc.ast, - &uri, - utf8, - &header, - level, - margin, - link_format, - cursor_line, - )) -} - -fn links_generate(backend: &Backend, args: Vec) -> Result, String> { - let (uri, cursor_line) = parse_uri_line_arg(args)?; - links_generate_scoped(backend, &uri, cursor_line, None) -} - -/// `:VimwikiGenerateLinks ` — scope the generated links to a subtree. -fn links_generate_for_path( - backend: &Backend, - args: Vec, -) -> Result, String> { - #[derive(Deserialize)] - struct Args { - uri: Url, - path: String, - #[serde(default)] - line: Option, - } - let raw = args.into_iter().next().ok_or_else(|| { - "nuwiki.links.generateForPath: missing { uri, path } argument".to_string() - })?; - let parsed: Args = serde_json::from_value(raw) - .map_err(|e| format!("nuwiki.links.generateForPath: invalid args — {e}"))?; - links_generate_scoped(backend, &parsed.uri, parsed.line, Some(&parsed.path)) -} - -/// True when page `name` falls within `scope`. A scope containing `*`/`?` is a -/// glob (upstream's pattern arg); otherwise it's a directory subtree — the page -/// itself or anything under `/`. Page names are wiki-root-relative with -/// `/` separators (e.g. `projects/api`). -fn page_in_scope(name: &str, scope: &str) -> bool { - let scope = scope.trim_matches('/'); - if scope.is_empty() { - return true; - } - if scope.contains('*') || scope.contains('?') { - crate::export::glob_match(scope, name) - } else { - name == scope || name.starts_with(&format!("{scope}/")) - } -} - -fn links_generate_scoped( - backend: &Backend, - uri: &Url, - cursor_line: Option, - scope: Option<&str>, -) -> Result, String> { - let doc = match backend.documents.get(uri) { - Some(d) => d, - None => return Ok(None), - }; - let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let wiki = match backend.wiki_for_uri(uri) { - Some(w) => w, - None => return Ok(None), - }; - let current_page = crate::index::page_name_from_uri(uri, Some(&wiki.config.root)); - let (pages, captions) = { - let idx = wiki - .index - .read() - .map_err(|_| "index lock poisoned".to_string())?; - let captions = wiki - .config - .generated_links_caption - .then(|| ops::page_captions(&idx)); - let names: Vec = match scope { - Some(s) => idx - .page_names() - .into_iter() - .filter(|n| page_in_scope(n, s)) - .collect(), - None => idx.page_names(), - }; - (names, captions) - }; - Ok(ops::links_edit( - &doc.text, - &doc.ast, - uri, - ¤t_page, - &pages, - utf8, - &wiki.config.links_header, - wiki.config.links_header_level, - wiki.config.list_margin.max(0) as usize, - captions.as_ref(), - cursor_line, - )) -} - -fn workspace_check_links(backend: &Backend, args: Vec) -> Result, String> { - let p = parse_optional_uri_arg(args)?; - let wiki = match p.uri.as_ref().and_then(|u| backend.wiki_for_uri(u)) { - Some(w) => w, - None => match backend.default_wiki() { - Some(w) => w, - None => return Ok(Some(serde_json::json!([]))), - }, - }; - let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let idx = wiki - .index - .read() - .map_err(|_| "index lock poisoned".to_string())?; - let broken = ops::collect_workspace_broken_links(backend, &idx, utf8); - Ok(Some(serde_json::to_value(broken).map_err(|e| { - format!("nuwiki.workspace.checkLinks: serialization failed — {e}") - })?)) -} - -#[derive(Clone, Copy)] -enum RelativeDay { - Today, - Yesterday, - Tomorrow, -} - -fn resolve_diary_wiki( - backend: &Backend, - uri: Option<&Url>, - wiki: Option<&Value>, -) -> Option { - // An explicit wiki selector (vimwiki's `-count`) wins over the buffer URI. - if let Some(sel) = wiki { - return resolve_wiki_selector(backend, Some(sel)); - } - if let Some(u) = uri { - if let Some(w) = backend.wiki_for_uri(u) { - return Some(w); - } - } - backend.default_wiki() -} - -fn diary_open_relative( - backend: &Backend, - args: Vec, - rel: RelativeDay, -) -> Result, String> { - let p = parse_optional_uri_arg(args)?; - let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref(), p.wiki.as_ref()) else { - return Ok(None); - }; - let freq = wiki.config.frequency(); - let calendar = wiki.config.diary_calendar(); - let today_period = calendar.today(); - let period = match rel { - RelativeDay::Today => today_period, - RelativeDay::Yesterday => calendar.prev(today_period), - RelativeDay::Tomorrow => calendar.next(today_period), - }; - let Some(uri) = crate::diary::uri_for_period(&wiki.config, &period) else { - return Ok(None); - }; - Ok(Some(serde_json::json!({ - "uri": uri, - "date": period.format(), - "frequency": freq.as_str(), - }))) -} - -fn diary_open_index(backend: &Backend, args: Vec) -> Result, String> { - let p = parse_optional_uri_arg(args)?; - let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref(), p.wiki.as_ref()) else { - return Ok(None); - }; - let Some(uri) = crate::diary::index_uri(&wiki.config) else { - return Ok(None); - }; - Ok(Some(serde_json::json!({ "uri": uri }))) -} - -fn diary_generate_index( - backend: &Backend, - args: Vec, -) -> Result, String> { - let p = parse_optional_uri_arg(args)?; - let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref(), p.wiki.as_ref()) else { - return Ok(None); - }; - let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let Some(index_uri) = crate::diary::index_uri(&wiki.config) else { - return Ok(None); - }; - let body = { - let idx = wiki - .index - .read() - .map_err(|_| "index lock poisoned".to_string())?; - crate::diary::render_index_body(&wiki.config, &idx) - }; - Ok(Some(ops::diary_generate_index_edit( - backend, &index_uri, &body, utf8, - ))) -} - -fn diary_step(backend: &Backend, args: Vec, forward: bool) -> Result, String> { - let p = parse_uri_arg(args)?; - let Some(wiki) = backend.wiki_for_uri(&p.uri) else { - return Ok(None); - }; - let next = { - let idx = wiki - .index - .read() - .map_err(|_| "index lock poisoned".to_string())?; - // Pivot period: prefer the current page's own period (any flavour), - // falling back to the wiki's configured frequency for "today" when - // the cursor isn't on a diary page. This makes on a weekly - // entry jump to the next weekly entry, not the next daily one. - let pivot: nuwiki_core::date::DiaryPeriod = idx - .page(&p.uri) - .and_then(|page| page.diary_period) - .unwrap_or_else(|| wiki.config.diary_calendar().today()); - if forward { - crate::diary::next_period(&idx, &pivot) - } else { - crate::diary::prev_period(&idx, &pivot) - } - }; - match next { - None => Ok(None), - Some(entry) => Ok(Some(serde_json::to_value(&entry).map_err(|e| { - format!("nuwiki.diary.next/prev: serialization failed — {e}") - })?)), - } -} - -fn diary_list(backend: &Backend, args: Vec) -> Result, String> { - #[derive(Deserialize, Default)] - struct Args { - #[serde(default)] - uri: Option, - #[serde(default)] - year: Option, - #[serde(default)] - month: Option, - } - let parsed: Args = match args.into_iter().next() { - Some(v) => serde_json::from_value(v).map_err(|e| format!("invalid args: {e}"))?, - None => Args::default(), - }; - let Some(wiki) = resolve_diary_wiki(backend, parsed.uri.as_ref(), None) else { - return Ok(Some(serde_json::json!([]))); - }; - let entries = { - let idx = wiki - .index - .read() - .map_err(|_| "index lock poisoned".to_string())?; - crate::diary::list_entries_filtered(&idx, parsed.year, parsed.month) - }; - Ok(Some(serde_json::to_value(&entries).map_err(|e| { - format!("nuwiki.diary.listEntries: serialization failed — {e}") - })?)) -} - -fn diary_open_for_date(backend: &Backend, args: Vec) -> Result, String> { - #[derive(Deserialize)] - struct Args { - date: String, - #[serde(default)] - uri: Option, - } - let raw = args - .into_iter() - .next() - .ok_or_else(|| "nuwiki.diary.openForDate: missing { date } argument".to_string())?; - let parsed: Args = serde_json::from_value(raw) - .map_err(|e| format!("nuwiki.diary.openForDate: invalid args — {e}"))?; - let Some(date) = nuwiki_core::date::DiaryDate::parse(&parsed.date) else { - return Err(format!( - "nuwiki.diary.openForDate: '{}' is not a YYYY-MM-DD date", - parsed.date - )); - }; - let Some(wiki) = resolve_diary_wiki(backend, parsed.uri.as_ref(), None) else { - return Ok(None); - }; - let Some(uri) = crate::diary::uri_for_date(&wiki.config, &date) else { - return Ok(None); - }; - Ok(Some( - serde_json::json!({ "uri": uri, "date": date.format() }), - )) -} - -fn tags_search(backend: &Backend, args: Vec) -> Result, String> { - #[derive(Deserialize, Default)] - struct Args { - #[serde(default)] - uri: Option, - #[serde(default)] - query: Option, - } - let parsed: Args = match args.into_iter().next() { - Some(v) => serde_json::from_value(v).map_err(|e| format!("invalid args: {e}"))?, - None => Args::default(), - }; - let Some(wiki) = resolve_diary_wiki(backend, parsed.uri.as_ref(), None) else { - return Ok(Some(serde_json::json!([]))); - }; - let hits = { - let idx = wiki - .index - .read() - .map_err(|_| "index lock poisoned".to_string())?; - ops::tag_hits(&idx, parsed.query.as_deref().unwrap_or("")) - }; - Ok(Some(serde_json::to_value(&hits).map_err(|e| { - format!("nuwiki.tags.search: serialization failed — {e}") - })?)) -} - -fn tags_generate_links( - backend: &Backend, - args: Vec, -) -> Result, String> { - #[derive(Deserialize)] - struct Args { - uri: Url, - #[serde(default)] - tag: Option, - /// 0-based cursor line; a fresh tags section is inserted here. - #[serde(default)] - line: Option, - } - let raw = args - .into_iter() - .next() - .ok_or_else(|| "nuwiki.tags.generateLinks: missing { uri } argument".to_string())?; - let parsed: Args = serde_json::from_value(raw) - .map_err(|e| format!("nuwiki.tags.generateLinks: invalid args — {e}"))?; - let doc = match backend.documents.get(&parsed.uri) { - Some(d) => d, - None => return Ok(None), - }; - let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let Some(wiki) = backend.wiki_for_uri(&parsed.uri) else { - return Ok(None); - }; - let snapshot = { - let idx = wiki - .index - .read() - .map_err(|_| "index lock poisoned".to_string())?; - ops::tag_pages_snapshot(&idx) - }; - Ok(ops::tag_links_edit( - &doc.text, - &doc.ast, - &parsed.uri, - parsed.tag.as_deref(), - &snapshot, - utf8, - &wiki.config.tags_header, - wiki.config.tags_header_level, - wiki.config.list_margin.max(0) as usize, - parsed.line, - )) -} - -fn tags_rebuild(backend: &Backend, args: Vec) -> Result, String> { - // `:VimwikiRebuildTags!` passes `{ all: true }` to re-index every - // configured wiki (upstream's bang variant); otherwise just the current. - let all = args - .first() - .and_then(|v| v.get("all")) - .and_then(Value::as_bool) - .unwrap_or(false); - let p = parse_optional_uri_arg(args)?; - let registry = std::sync::Arc::clone(&backend.registry); - let Some(plugin) = registry.get("vimwiki") else { - return Err("vimwiki syntax plugin missing".to_string()); - }; - let targets: Vec = if all { - backend.wikis_snapshot() - } else { - match resolve_diary_wiki(backend, p.uri.as_ref(), None) { - Some(w) => vec![w], - None => return Ok(None), - } - }; - - let mut indexed = 0usize; - let mut total = 0usize; - let mut stale_removed = 0usize; - let mut wikis_done = 0usize; - for wiki in &targets { - let root = wiki.config.root.clone(); - if root.as_os_str().is_empty() { - continue; - } - let files = crate::index::walk_wiki_files(&root); - total += files.len(); - let mut idx = wiki - .index - .write() - .map_err(|_| "index lock poisoned".to_string())?; - // Drop pages whose files no longer exist. Collect first to avoid - // mutating during iteration. - let stale: Vec = idx - .pages_by_uri - .keys() - .filter(|u| match u.to_file_path() { - Ok(p) => !p.exists(), - Err(_) => true, - }) - .cloned() - .collect(); - for u in &stale { - idx.remove(u); - } - stale_removed += stale.len(); - for path in files { - let Ok(text) = std::fs::read_to_string(&path) else { - continue; - }; - let ast = plugin.parse(&text); - let Ok(uri) = Url::from_file_path(&path) else { - continue; - }; - idx.upsert(uri, &ast); - indexed += 1; - } - wikis_done += 1; - } - - Ok(Some(serde_json::json!({ - "pages": indexed, - "files_seen": total, - "stale_removed": stale_removed, - "wikis": wikis_done, - "all": all, - }))) -} - -// ===== multi-wiki picker commands ===== - -fn wiki_list_all(backend: &Backend) -> Result, String> { - let wikis = backend.wikis_snapshot(); - let rows: Vec = wikis - .iter() - .enumerate() - .map(|(i, w)| { - serde_json::json!({ - "id": i, - "name": w.config.name, - "root": w.config.root, - "syntax": w.config.syntax, - "file_extension": w.config.file_extension, - }) - }) - .collect(); - Ok(Some(serde_json::json!(rows))) -} - -fn resolve_wiki_selector(backend: &Backend, sel: Option<&Value>) -> Option { - let wikis = backend.wikis_snapshot(); - let Some(sel) = sel else { - return wikis.into_iter().next(); - }; - if let Some(idx) = sel.as_u64() { - return wikis.into_iter().nth(idx as usize); - } - if let Some(name) = sel.as_str() { - // Numeric strings are also accepted so the picker UI can pass - // back whatever it received without coercing. - if let Ok(idx) = name.parse::() { - if let Some(w) = wikis.get(idx).cloned() { - return Some(w); - } - } - return wikis.into_iter().find(|w| w.config.name == name); - } - None -} - -fn wiki_open_index( - backend: &Backend, - args: Vec, - tab: bool, -) -> Result, String> { - #[derive(Deserialize, Default)] - struct Args { - #[serde(default)] - wiki: Option, - } - let parsed: Args = match args.into_iter().next() { - Some(v) => serde_json::from_value(v).map_err(|e| format!("invalid args: {e}"))?, - None => Args::default(), - }; - let Some(wiki) = resolve_wiki_selector(backend, parsed.wiki.as_ref()) else { - return Ok(None); - }; - let index_path = wiki.config.root.join(format!( - "{}{}", - wiki.config.index, wiki.config.file_extension - )); - let Ok(uri) = Url::from_file_path(&index_path) else { - return Ok(None); - }; - Ok(Some(serde_json::json!({ - "uri": uri, - "name": wiki.config.name, - "tab": tab, - }))) -} - -#[derive(Clone, Copy)] -enum PasteKind { - Wikilink, - Url, -} - -fn link_paste( - backend: &Backend, - args: Vec, - kind: PasteKind, -) -> Result, String> { - let p = parse_pos(args)?; - let Some(wiki) = backend.wiki_for_uri(&p.uri) else { - return Ok(None); - }; - let name = crate::index::page_name_from_uri(&p.uri, Some(&wiki.config.root)); - if name.is_empty() { - return Ok(None); - } - let snippet = match kind { - PasteKind::Wikilink => format!("[[{name}]]"), - PasteKind::Url => { - // Mirror `export::output_path_for` — the URL is the path - // beneath `html_path`, relative to the *exported root*, so - // pasted links survive when the HTML output moves. - let mut url = String::new(); - for (i, seg) in name.split('/').enumerate() { - if i > 0 { - url.push('/'); - } - url.push_str(seg); - } - url.push_str(".html"); - url - } - }; - let mut b = WorkspaceEditBuilder::new(); - b.edit(p.uri, crate::edits::text_edit_insert(p.position, snippet)); - Ok(Some(b.build())) -} - -fn wiki_goto_page(backend: &Backend, args: Vec) -> Result, String> { - #[derive(Deserialize)] - struct Args { - page: String, - #[serde(default)] - wiki: Option, - } - let raw = args - .into_iter() - .next() - .ok_or_else(|| "nuwiki.wiki.gotoPage: missing { page } argument".to_string())?; - let parsed: Args = serde_json::from_value(raw) - .map_err(|e| format!("nuwiki.wiki.gotoPage: invalid args — {e}"))?; - let Some(wiki) = resolve_wiki_selector(backend, parsed.wiki.as_ref()) else { - return Ok(None); - }; - // Prefer an indexed page first (canonical URI from the workspace - // index), then fall back to building the path from the page name. - let uri = { - let idx = wiki - .index - .read() - .map_err(|_| "index lock poisoned".to_string())?; - idx.pages_by_name.get(&parsed.page).cloned() - }; - let uri = uri.unwrap_or_else(|| { - // Build `/.wiki` directly. `page` may contain - // forward-slashes for subdirs. - let mut path = wiki.config.root.clone(); - for seg in parsed.page.split('/') { - if !seg.is_empty() { - path.push(seg); - } - } - let path = path.with_extension(wiki.config.file_extension.trim_start_matches('.')); - Url::from_file_path(path) - .unwrap_or_else(|_| Url::parse("file:///nonexistent").expect("constant url")) - }); - Ok(Some(serde_json::json!({ - "uri": uri, - "page": parsed.page, - "wiki": wiki.config.name, - }))) -} - -// ===== Cluster A: list rewriters ===== - -fn list_remove_done(backend: &Backend, args: Vec) -> Result, String> { - let p = parse_optional_uri_arg(args.clone())?; - // Accept both `{ uri }` (whole doc) and `{ uri, position }` (item under - // cursor + its descendants). Position is optional — when absent, we - // strip every done/rejected item in the doc. - let raw = args.into_iter().next().unwrap_or(serde_json::json!({})); - #[derive(Deserialize, Default)] - struct Args { - uri: Option, - #[serde(default)] - position: Option, - /// `[line1, line2]` (0-indexed, inclusive) for the ranged form. - #[serde(default)] - range: Option<[u32; 2]>, - } - let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?; - let uri = parsed - .uri - .or(p.uri) - .ok_or_else(|| "missing uri".to_string())?; - let doc = match backend.documents.get(&uri) { - Some(d) => d, - None => return Ok(None), - }; - let utf8 = backend.use_utf8.load(Ordering::Relaxed); - // A range takes precedence over a cursor position; the client sends at - // most one (range for `:'<,'>RemoveDone`, position for the current list). - let range = parsed.range.map(|[a, b]| (a.min(b), a.max(b))); - let pos = if range.is_some() { - None - } else { - parsed.position - }; - Ok(ops::remove_done_edit( - &doc.text, &doc.ast, &uri, pos, range, utf8, - )) -} - -fn list_remove_checkbox( - backend: &Backend, - args: Vec, -) -> Result, String> { - #[derive(Deserialize)] - struct Args { - uri: Url, - position: LspPosition, - #[serde(default)] - whole_list: bool, - } - let raw = args - .into_iter() - .next() - .ok_or_else(|| "missing { uri, position } argument".to_string())?; - let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?; - let doc = match backend.documents.get(&parsed.uri) { - Some(d) => d, - None => return Ok(None), - }; - let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let (line, _) = nav::lsp_to_byte_pos(parsed.position, &doc.text, utf8); - Ok(ops::remove_checkbox_edit( - &doc.text, - &doc.ast, - &parsed.uri, - line, - parsed.whole_list, - utf8, - )) -} - -/// Compute the `file://` URL of the current page's HTML export, returned -/// to the client. Mirrors vimwiki's `VimwikiCatUrl`, which echoes -/// `file://` for the current file. -fn link_cat_url(backend: &Backend, args: Vec) -> Result, String> { - let p = parse_uri_arg(args)?; - let Some(wiki) = backend.wiki_for_uri(&p.uri) else { - return Ok(None); - }; - let cfg = &wiki.config; - let name = crate::index::page_name_from_uri(&p.uri, Some(&cfg.root)); - let out_path = crate::export::output_path_for(&cfg.html, &name); - let url = Url::from_file_path(&out_path) - .map(|u| u.to_string()) - .unwrap_or_else(|_| format!("file://{}", out_path.display())); - Ok(Some(Value::from(url))) -} - -fn list_renumber(backend: &Backend, args: Vec) -> Result, String> { - #[derive(Deserialize)] - struct Args { - uri: Url, - #[serde(default)] - position: Option, - #[serde(default)] - whole_file: bool, - } - let raw = args - .into_iter() - .next() - .ok_or_else(|| "missing { uri } argument".to_string())?; - let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?; - let doc = match backend.documents.get(&parsed.uri) { - Some(d) => d, - None => return Ok(None), - }; - let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let (line, _) = match parsed.position { - Some(pos) => nav::lsp_to_byte_pos(pos, &doc.text, utf8), - None => (0, 0), - }; - Ok(ops::renumber_edit( - &doc.text, - &doc.ast, - &parsed.uri, - line, - parsed.whole_file, - utf8, - )) -} - -fn list_change_symbol( - backend: &Backend, - args: Vec, -) -> Result, String> { - #[derive(Deserialize)] - struct Args { - uri: Url, - position: LspPosition, - symbol: String, - #[serde(default)] - whole_list: bool, - } - let raw = args - .into_iter() - .next() - .ok_or_else(|| "missing { uri, position, symbol } argument".to_string())?; - let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?; - let target = ops::parse_symbol(&parsed.symbol) - .ok_or_else(|| format!("unknown list symbol: {}", parsed.symbol))?; - let doc = match backend.documents.get(&parsed.uri) { - Some(d) => d, - None => return Ok(None), - }; - let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let (line, _) = nav::lsp_to_byte_pos(parsed.position, &doc.text, utf8); - Ok(ops::change_symbol_edit( - &doc.text, - &doc.ast, - &parsed.uri, - line, - target, - parsed.whole_list, - utf8, - )) -} - -fn list_change_level(backend: &Backend, args: Vec) -> Result, String> { - #[derive(Deserialize)] - struct Args { - uri: Url, - position: LspPosition, - delta: i32, - #[serde(default)] - whole_subtree: bool, - } - let raw = args - .into_iter() - .next() - .ok_or_else(|| "missing { uri, position, delta } argument".to_string())?; - let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?; - let doc = match backend.documents.get(&parsed.uri) { - Some(d) => d, - None => return Ok(None), - }; - let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let (line, _) = nav::lsp_to_byte_pos(parsed.position, &doc.text, utf8); - let (cycle_bullets, bullet_types) = backend - .wiki_for_uri(&parsed.uri) - .map(|w| (w.config.cycle_bullets, w.config.bullet_types.clone())) - .unwrap_or((false, Vec::new())); - Ok(ops::change_level_edit( - &doc.text, - &doc.ast, - &parsed.uri, - line, - parsed.delta, - parsed.whole_subtree, - utf8, - cycle_bullets, - &bullet_types, - )) -} - -// ===== Cluster B: table rewriters ===== - -fn table_insert(_backend: &Backend, args: Vec) -> Result, String> { - #[derive(Deserialize)] - struct Args { - uri: Url, - position: LspPosition, - cols: usize, - rows: usize, - } - let raw = args - .into_iter() - .next() - .ok_or_else(|| "missing { uri, position, cols, rows }".to_string())?; - let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?; - if parsed.cols == 0 || parsed.rows == 0 { - return Ok(None); - } - let text = ops::render_blank_table(parsed.cols, parsed.rows); - let mut b = WorkspaceEditBuilder::new(); - b.edit( - parsed.uri, - crate::edits::text_edit_insert(parsed.position, text), - ); - Ok(Some(b.build())) -} - -fn table_align(backend: &Backend, args: Vec) -> Result, String> { - let p = parse_pos(args)?; - let doc = match backend.documents.get(&p.uri) { - Some(d) => d, - None => return Ok(None), - }; - let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let (line, _) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8); - let reduce = backend - .wiki_for_uri(&p.uri) - .map(|w| w.config.table_reduce_last_col) - .unwrap_or(false); - Ok(ops::table_align_edit( - &doc.text, &doc.ast, &p.uri, line, utf8, reduce, - )) -} - -fn table_move_column(backend: &Backend, args: Vec) -> Result, String> { - #[derive(Deserialize)] - struct Args { - uri: Url, - position: LspPosition, - #[serde(default)] - dir: Option, - } - let raw = args - .into_iter() - .next() - .ok_or_else(|| "missing { uri, position, dir }".to_string())?; - let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?; - let dir = parsed.dir.as_deref().unwrap_or("right"); - let delta = match dir { - "left" => -1i32, - "right" => 1, - other => return Err(format!("unknown direction: {other}")), - }; - let doc = match backend.documents.get(&parsed.uri) { - Some(d) => d, - None => return Ok(None), - }; - let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let (line, col) = nav::lsp_to_byte_pos(parsed.position, &doc.text, utf8); - let reduce = backend - .wiki_for_uri(&parsed.uri) - .map(|w| w.config.table_reduce_last_col) - .unwrap_or(false); - Ok(ops::table_move_column_edit( - &doc.text, - &doc.ast, - &parsed.uri, - line, - col, - delta, - utf8, - reduce, - )) -} - -// ===== HTML export commands ===== - -fn export_current( - backend: &Backend, - args: Vec, - return_open_url: bool, -) -> Result, String> { - let p = parse_uri_arg(args)?; - let doc = match backend.documents.get(&p.uri) { - Some(d) => d, - None => return Ok(None), - }; - let Some(wiki) = backend.wiki_for_uri(&p.uri) else { - return Ok(None); - }; - let cfg = &wiki.config; - if doc.ast.metadata.nohtml { - return Ok(Some(serde_json::json!({ - "exported": [], - "skipped": [{"uri": p.uri, "reason": "nohtml"}], - }))); - } - let name = crate::index::page_name_from_uri(&p.uri, Some(&cfg.root)); - let outcome = match crate::commands::export_ops::write_page(&doc.ast, &name, cfg, false) { - Ok(o) => o, - Err(e) => return Err(format!("nuwiki.export.currentToHtml: {e}")), - }; - let css = crate::commands::export_ops::ensure_css(cfg).map_err(|e| e.to_string())?; - let mut body = serde_json::json!({ - "exported": [outcome.to_json()], - "css_path": css.path, - "css_created": css.created, - }); - if return_open_url { - if let Ok(url) = Url::from_file_path(&outcome.output_path) { - body["browse"] = serde_json::json!(url); - } - } - Ok(Some(body)) -} - -fn export_all(backend: &Backend, args: Vec, force: bool) -> Result, String> { - let p = parse_optional_uri_arg(args)?; - let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref(), None) else { - return Ok(None); - }; - let cfg = wiki.config.clone(); - if cfg.root.as_os_str().is_empty() { - return Ok(Some(serde_json::json!({ - "exported": [], - "reason": "no wiki root configured", - }))); - } - let registry = std::sync::Arc::clone(&backend.registry); - if registry.get("vimwiki").is_none() { - return Err("vimwiki syntax plugin missing".to_string()); - } - let files = crate::index::walk_wiki_files(&cfg.root); - let mut exported: Vec = Vec::new(); - let mut skipped: Vec = Vec::new(); - for path in &files { - let Ok(uri) = Url::from_file_path(path) else { - continue; - }; - let name = crate::index::page_name_from_uri(&uri, Some(&cfg.root)); - if crate::export::is_excluded(&cfg.html.exclude_files, &name) { - skipped.push(serde_json::json!({"uri": uri, "reason": "excluded"})); - continue; - } - let (text, ast) = match backend.documents.get(&uri) { - Some(d) => (d.text.clone(), d.ast.clone()), - None => { - let Ok(text) = std::fs::read_to_string(path) else { - continue; - }; - let ast = nuwiki_core::syntax::vimwiki::VimwikiSyntax::new() - .parse_with_listsyms(&text, &cfg.list_syms()); - (text, ast) - } - }; - let _ = text; - if ast.metadata.nohtml { - skipped.push(serde_json::json!({"uri": uri, "reason": "nohtml"})); - continue; - } - let out_path = crate::export::output_path_for(&cfg.html, &name); - if !force { - // Compare modification times. Skip when the HTML is newer than - // the source. Cheap and matches vimwiki's incremental behaviour. - if let (Ok(src_meta), Ok(html_meta)) = - (std::fs::metadata(path), std::fs::metadata(&out_path)) - { - let src_mtime = src_meta.modified().ok(); - let out_mtime = html_meta.modified().ok(); - if let (Some(s), Some(o)) = (src_mtime, out_mtime) { - if o >= s { - skipped.push(serde_json::json!({"uri": uri, "reason": "up-to-date"})); - continue; - } - } - } - } - match crate::commands::export_ops::write_page(&ast, &name, &cfg, force) { - Ok(outcome) => exported.push(outcome.to_json()), - Err(e) => skipped.push(serde_json::json!({"uri": uri, "reason": e.to_string()})), - } - } - let css = crate::commands::export_ops::ensure_css(&cfg).map_err(|e| e.to_string())?; - // Prune orphan HTML (no wiki source), honouring user_htmls. Skipped when - // filename parameterization is on (slugified names can't reverse-map), - // mirroring upstream. - let pruned: Vec = if cfg.html.html_filename_parameterization { - Vec::new() - } else { - crate::commands::export_ops::prune_orphan_html(&cfg) - .into_iter() - .map(|p| serde_json::json!(p)) - .collect() - }; - Ok(Some(serde_json::json!({ - "exported": exported, - "skipped": skipped, - "pruned": pruned, - "files_seen": files.len(), - "css_path": css.path, - "css_created": css.created, - "forced": force, - }))) -} - -fn export_rss(backend: &Backend, args: Vec) -> Result, String> { - let p = parse_optional_uri_arg(args)?; - let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref(), None) else { - return Ok(None); - }; - let entries = { - let idx = wiki - .index - .read() - .map_err(|_| "index lock poisoned".to_string())?; - crate::diary::list_entries(&idx) - }; - let out_path = crate::commands::export_ops::write_rss(&wiki.config, &entries) - .map_err(|e| format!("nuwiki.export.rss: {e}"))?; - Ok(Some(serde_json::json!({ - "rss_path": out_path, - "entries": entries.len(), - }))) -} - -fn workspace_find_orphans(backend: &Backend, args: Vec) -> Result, String> { - let p = parse_optional_uri_arg(args)?; - let wiki = match p.uri.as_ref().and_then(|u| backend.wiki_for_uri(u)) { - Some(w) => w, - None => match backend.default_wiki() { - Some(w) => w, - None => return Ok(Some(serde_json::json!([]))), - }, - }; - let idx = wiki - .index - .read() - .map_err(|_| "index lock poisoned".to_string())?; - let orphans = ops::find_orphans(&idx); - Ok(Some(serde_json::to_value(orphans).map_err(|e| { - format!("nuwiki.workspace.findOrphans: serialization failed — {e}") - })?)) -} - -/// Return the end-of-file `LspPosition` (1-based lines; 0-based -/// character column) for the given text, suitable for appending. -fn eof_position(text: &str) -> LspPosition { - let mut line = 0u32; - let mut last_nl_col = 0u32; - let mut col = 0u32; - for (i, b) in text.as_bytes().iter().copied().enumerate() { - if b == b'\n' { - line += 1; - last_nl_col = (i + 1) as u32; - col = 0; - } else { - col = (i + 1 - last_nl_col as usize) as u32; - } - } - LspPosition { - line, - character: col, - } -} - -/// Decide where to insert a generated section (TOC / generated links / tags -/// index) and the text block to write. When the client passed a cursor -/// `insert_line`, insert at that line (clamped to the document; a leading blank -/// keeps the heading off the preceding line, except at the top of the file). -/// Otherwise fall back to `fallback` — top of file for the TOC, end of file for -/// the links/tags sections. -fn section_insert( - text: &str, - insert_line: Option, - fallback: LspPosition, - new_text: &str, -) -> (LspPosition, String) { - match insert_line { - Some(l) => { - let line = l.min(text.matches('\n').count() as u32); - let block = if line == 0 { - format!("{new_text}\n") - } else { - format!("\n{new_text}\n") - }; - (LspPosition { line, character: 0 }, block) - } - None => (fallback, format!("{new_text}\n")), - } -} - -// ===== Pure operations ===== - -pub mod ops { - use super::*; - use crate::edits::text_edit_replace; - use nuwiki_core::ast::{ - BlockNode, BlockquoteNode, CheckboxState, DocumentNode, HeadingNode, ListItemNode, - ListNode, Span, - }; - use nuwiki_core::listsyms::ListSyms; - use tower_lsp::lsp_types::Location; - - /// The single glyph inside a `[g]` checkbox marker, or `None` when - /// `marker` isn't a well-formed one-glyph marker. - fn marker_glyph(marker: &str) -> Option { - let inner = marker.strip_prefix('[')?.strip_suffix(']')?; - let mut chars = inner.chars(); - let glyph = chars.next()?; - if chars.next().is_some() { - return None; - } - Some(glyph) - } - - fn marker_for(glyph: char) -> String { - format!("[{glyph}]") - } - - /// Toggle empty ↔ done. Mid-states snap forward to done; rejected reverts - /// to empty. - pub fn toggle_state(syms: &ListSyms, current: &str) -> Option { - let glyph = marker_glyph(current)?; - if glyph == syms.rejected_glyph() { - return Some(marker_for(syms.empty_glyph())); - } - let index = syms.index_of(glyph)?; - if index == syms.len() - 1 { - Some(marker_for(syms.empty_glyph())) - } else { - Some(marker_for(syms.done_glyph())) - } - } - - /// Advance one step through the palette, wrapping done → empty. - /// Rejected reverts to empty. - pub fn cycle_state(syms: &ListSyms, current: &str) -> Option { - let glyph = marker_glyph(current)?; - if glyph == syms.rejected_glyph() { - return Some(marker_for(syms.empty_glyph())); - } - let index = syms.index_of(glyph)?; - let next = if index + 1 >= syms.len() { - 0 - } else { - index + 1 - }; - Some(marker_for(syms.glyph_at(next))) - } - - /// Exact inverse of [`cycle_state`] — used by the `glp` mapping - /// ("cycle the checkbox state backward"). Rejected reverts to empty. - pub fn cycle_state_back(syms: &ListSyms, current: &str) -> Option { - let glyph = marker_glyph(current)?; - if glyph == syms.rejected_glyph() { - return Some(marker_for(syms.empty_glyph())); - } - let index = syms.index_of(glyph)?; - let prev = if index == 0 { - syms.len() - 1 - } else { - index - 1 - }; - Some(marker_for(syms.glyph_at(prev))) - } - - /// Toggle the rejected state. - pub fn reject_state(syms: &ListSyms, current: &str) -> Option { - let glyph = marker_glyph(current)?; - if glyph == syms.rejected_glyph() { - Some(marker_for(syms.empty_glyph())) - } else { - Some(marker_for(syms.rejected_glyph())) - } - } - - #[allow(clippy::too_many_arguments)] - pub fn checkbox_edit( - text: &str, - ast: &DocumentNode, - uri: &Url, - line: u32, - col: u32, - utf8: bool, - syms: &ListSyms, - mutate: fn(&ListSyms, &str) -> Option, - propagate: bool, - ) -> Option { - let path = find_path_to_item(ast, line, col)?; - let leaf = *path.last()?; - let cb_span = find_checkbox_span(text, leaf.span)?; - let cur = &text[cb_span.start.offset..cb_span.end.offset]; - let new = mutate(syms, cur)?; - let mut b = WorkspaceEditBuilder::new(); - b.edit( - uri.clone(), - text_edit_replace(cb_span, new.clone(), text, utf8), - ); - - if propagate && path.len() > 1 { - // Vimwiki's listsyms_propagate: walk up the chain, recomputing - // each ancestor's marker from the average rate of its immediate - // children (using the modified child's *new* state). Stops at - // the first ancestor that has no checkbox. - let mut child_node: &ListItemNode = leaf; - let mut child_marker: String = new; - for parent in path.iter().rev().skip(1) { - let parent_cb_span = match find_checkbox_span(text, parent.span) { - Some(s) => s, - None => break, - }; - let parent_cur = &text[parent_cb_span.start.offset..parent_cb_span.end.offset]; - let new_parent = - match compute_parent_marker(syms, parent, child_node, &child_marker) { - Some(m) => m, - None => break, - }; - if new_parent == parent_cur { - break; - } - b.edit( - uri.clone(), - text_edit_replace(parent_cb_span, new_parent.clone(), text, utf8), - ); - child_node = parent; - child_marker = new_parent; - } - } - - Some(b.build()) - } - - /// Convert a marker string like `"[X]"` to its progress rate. `-1` - /// signals rejected (handled specially when averaging). Returns - /// `None` for markers whose glyph isn't in the palette. - fn marker_to_rate(syms: &ListSyms, marker: &str) -> Option { - let glyph = marker_glyph(marker)?; - if glyph == syms.rejected_glyph() { - return Some(-1); - } - Some(syms.rate(syms.index_of(glyph)?)) - } - - fn checkbox_state_to_rate(state: CheckboxState) -> i32 { - match state { - CheckboxState::Empty => 0, - CheckboxState::Quarter => 25, - CheckboxState::Half => 50, - CheckboxState::ThreeQuarters => 75, - CheckboxState::Done => 100, - CheckboxState::Rejected => -1, - } - } - - /// Inverse of `marker_to_rate`. `rate` is 0..=100 or `-1` (rejected). - /// Mirrors vimwiki's `s:rate_to_state`, generalised over the palette. - fn rate_to_marker(syms: &ListSyms, rate: i32) -> String { - marker_for(syms.glyph_for_rate(rate)) - } - - /// Compute the new marker for `parent` given that `modified_child` - /// (one of its immediate sublist items) just became - /// `modified_marker`. Returns `None` if the parent has no children - /// with checkboxes — there's nothing to average from. - /// - /// Mirrors vimwiki's `s:update_state` averaging: rejected children - /// count as 100% for the average, but if *every* child is rejected - /// the parent itself becomes rejected. - fn compute_parent_marker( - syms: &ListSyms, - parent: &ListItemNode, - modified_child: &ListItemNode, - modified_marker: &str, - ) -> Option { - let sub = parent.sublist.as_ref()?; - let mut sum: i32 = 0; - let mut count_with_cb: i32 = 0; - let mut count_rejected: i32 = 0; - for item in &sub.items { - let rate = if std::ptr::eq(item, modified_child) { - marker_to_rate(syms, modified_marker)? - } else { - match item.checkbox { - Some(state) => checkbox_state_to_rate(state), - None => continue, - } - }; - count_with_cb += 1; - if rate == -1 { - count_rejected += 1; - sum += 100; - } else { - sum += rate; - } - } - if count_with_cb == 0 { - return None; - } - let new_rate = if count_rejected == count_with_cb { - -1 - } else { - sum / count_with_cb - }; - Some(rate_to_marker(syms, new_rate)) - } - - pub fn next_task( - ast: &DocumentNode, - uri: &Url, - text: &str, - line: u32, - _col: u32, - utf8: bool, - ) -> Option { - // Walk in source order; first task at line > cursor wins. Wraps to - // the start of the document if nothing found after cursor — matches - // vimwiki's `gnt`. - let mut items: Vec<&ListItemNode> = Vec::new(); - for block in &ast.children { - collect_list_items(block, &mut items); - } - let is_unfinished = |it: &&ListItemNode| match it.checkbox { - Some(CheckboxState::Done) | Some(CheckboxState::Rejected) => false, - Some(_) | None => it.checkbox.is_some(), - }; - let after = items - .iter() - .find(|it| it.span.start.line > line && is_unfinished(it)); - let chosen = after.or_else(|| items.iter().find(|it| is_unfinished(it)))?; - let span = chosen.span; - Some(Location { - uri: uri.clone(), - range: crate::edits::text_edit_replace(span, String::new(), text, utf8).range, - }) - } - - fn collect_list_items<'a>(block: &'a BlockNode, out: &mut Vec<&'a ListItemNode>) { - match block { - BlockNode::List(ListNode { items, .. }) => { - for it in items { - out.push(it); - if let Some(sub) = &it.sublist { - for sub_it in &sub.items { - collect_list_items_from_item(sub_it, out); - } - } - } - } - BlockNode::Blockquote(BlockquoteNode { children, .. }) => { - for c in children { - collect_list_items(c, out); - } - } - _ => {} - } - } - - fn collect_list_items_from_item<'a>(item: &'a ListItemNode, out: &mut Vec<&'a ListItemNode>) { - out.push(item); - if let Some(sub) = &item.sublist { - for sub_it in &sub.items { - collect_list_items_from_item(sub_it, out); - } - } - } - - pub fn heading_change_level( - text: &str, - ast: &DocumentNode, - uri: &Url, - line: u32, - _col: u32, - utf8: bool, - delta: i32, - ) -> Option { - let heading = find_heading_at(ast, line)?; - let current = heading.level as i32; - let new_level = (current + delta).clamp(1, 6) as u8; - if new_level == heading.level { - return None; - } - let new_text = rewrite_heading_with_level(text, heading.span, new_level)?; - let mut b = WorkspaceEditBuilder::new(); - b.edit( - uri.clone(), - text_edit_replace(heading.span, new_text, text, utf8), - ); - Some(b.build()) - } - - fn find_heading_at(ast: &DocumentNode, line: u32) -> Option<&HeadingNode> { - for block in &ast.children { - if let BlockNode::Heading(h) = block { - if h.span.start.line == line { - return Some(h); - } - } - } - None - } - - /// Rewrite a heading's source so it has `new_level` `=`s on each side. - /// Returns `None` if the source doesn't have a balanced `=` ... `=` - /// shape (defensive — the lexer should guarantee this, but we don't - /// crash if the buffer was edited mid-flight). - pub fn rewrite_heading_with_level(text: &str, span: Span, new_level: u8) -> Option { - if !(1..=6).contains(&new_level) { - return None; - } - let segment = &text[span.start.offset..span.end.offset]; - let leading_ws = segment - .bytes() - .take_while(|b| *b == b' ' || *b == b'\t') - .count(); - let after_ws = &segment[leading_ws..]; - let opening = after_ws.bytes().take_while(|b| *b == b'=').count(); - let trimmed_end = after_ws.trim_end_matches([' ', '\t']).len(); - let trailing = after_ws[..trimmed_end] - .bytes() - .rev() - .take_while(|b| *b == b'=') - .count(); - if opening == 0 || trailing == 0 || opening != trailing { - return None; - } - let middle = &after_ws[opening..trimmed_end - trailing]; - let trailing_ws = &after_ws[trimmed_end..]; - - let mut out = String::with_capacity(segment.len() + 2); - out.push_str(&segment[..leading_ws]); - for _ in 0..new_level { - out.push('='); - } - out.push_str(middle); - for _ in 0..new_level { - out.push('='); - } - out.push_str(trailing_ws); - Some(out) - } - - /// Find the `[X]` (or other checkbox) substring within a list item's - /// source span. Returns a span covering exactly the 3-byte run. - /// Searches only the marker line — a parent item's span covers its - /// nested sublist too, and we don't want a child's checkbox to look - /// like the parent's. - pub fn find_checkbox_span(text: &str, item_span: Span) -> Option { - let start = item_span.start.offset; - let end = item_span.end.offset.min(text.len()); - if start >= end { - return None; - } - let segment = &text[start..end]; - let bytes = segment.as_bytes(); - let line_end = bytes - .iter() - .position(|&b| b == b'\n') - .unwrap_or(bytes.len()); - let bytes = &bytes[..line_end]; - let mut i = 0; - while i + 2 < bytes.len() { - if bytes[i] == b'[' - && bytes[i + 2] == b']' - && matches!(bytes[i + 1], b' ' | b'.' | b'o' | b'O' | b'X' | b'-') - { - let abs_start = start + i; - let cb_start = nuwiki_core::ast::Position { - line: item_span.start.line, - column: item_span.start.column + i as u32, - offset: abs_start, - }; - let cb_end = nuwiki_core::ast::Position { - line: item_span.start.line, - column: item_span.start.column + (i + 3) as u32, - offset: abs_start + 3, - }; - return Some(Span::new(cb_start, cb_end)); - } - i += 1; - } - None - } - - /// Find the list item whose source line contains `line`. Descends into - /// sublists; returns the deepest match so editing the inner item works - /// when the user's cursor sits there. - pub fn find_list_item_at(ast: &DocumentNode, line: u32, col: u32) -> Option<&ListItemNode> { - for block in &ast.children { - if let Some(it) = item_in_block(block, line, col) { - return Some(it); - } - } - None - } - - fn item_in_block(block: &BlockNode, line: u32, col: u32) -> Option<&ListItemNode> { - match block { - BlockNode::List(l) => l.items.iter().find_map(|it| item_in_list(it, line, col)), - BlockNode::Blockquote(BlockquoteNode { children, .. }) => { - children.iter().find_map(|c| item_in_block(c, line, col)) - } - _ => None, - } - } - - fn item_in_list(item: &ListItemNode, line: u32, col: u32) -> Option<&ListItemNode> { - // Prefer the deepest match: try sublists first. - if let Some(sub) = &item.sublist { - for sub_it in &sub.items { - if let Some(found) = item_in_list(sub_it, line, col) { - return Some(found); - } - } - } - if (item.span.start.line..=item.span.end.line).contains(&line) { - return Some(item); - } - let _ = col; - None - } - - /// Return the chain of list items from outermost ancestor to the - /// deepest item containing `line`. Used by checkbox propagation so - /// it can update each ancestor in turn. - pub fn find_path_to_item( - ast: &DocumentNode, - line: u32, - col: u32, - ) -> Option> { - for block in &ast.children { - if let Some(path) = path_in_block(block, line, col) { - return Some(path); - } - } - None - } - - fn path_in_block(block: &BlockNode, line: u32, col: u32) -> Option> { - match block { - BlockNode::List(l) => l.items.iter().find_map(|it| path_in_item(it, line, col)), - BlockNode::Blockquote(BlockquoteNode { children, .. }) => { - children.iter().find_map(|c| path_in_block(c, line, col)) - } - _ => None, - } - } - - fn path_in_item(item: &ListItemNode, line: u32, col: u32) -> Option> { - if let Some(sub) = &item.sublist { - for sub_it in &sub.items { - if let Some(mut deeper) = path_in_item(sub_it, line, col) { - let mut path = Vec::with_capacity(deeper.len() + 1); - path.push(item); - path.append(&mut deeper); - return Some(path); - } - } - } - if (item.span.start.line..=item.span.end.line).contains(&line) { - return Some(vec![item]); - } - let _ = col; - None - } - - // ===== TOC + Links generation, workspace queries ===== - - use serde::Serialize; - - pub const TOC_HEADING: &str = "Contents"; - pub const LINKS_HEADING: &str = "Generated Links"; - - use crate::diagnostics::classify_outgoing; - use crate::edits::text_edit_insert; - use crate::index::WorkspaceIndex; - use nuwiki_core::ast::Position as AstPosition; - - /// One heading entry used to render the TOC. - struct TocItem { - level: u8, - title: String, - anchor: String, - } - - /// A `= Heading =` line at the given level (clamped 1..=6), with trailing - /// newline. Drives the configurable `*_header` / `*_header_level` captions. - pub fn caption_line(name: &str, level: u8) -> String { - let eq = "=".repeat((level as usize).clamp(1, 6)); - format!("{eq} {name} {eq}\n") - } - - /// Build a heading + nested list TOC for the current document. The TOC - /// itself (any existing `= Contents =` heading on the page) is skipped - /// so re-generation is idempotent. - pub fn build_toc_text( - items: &[(u8, String, String)], - heading_name: &str, - level: u8, - margin: usize, - link_format: u8, - ) -> String { - let mut out = caption_line(heading_name, level); - if items.is_empty() { - return out; - } - let pad = " ".repeat(margin); - let min_level = items.iter().map(|(l, _, _)| *l).min().unwrap_or(1); - for (level, title, anchor) in items { - let depth = level.saturating_sub(min_level) as usize; - out.push_str(&pad); - for _ in 0..depth { - out.push_str(" "); - } - // vimwiki `toc_link_format`: 0 = `[[#anchor|title]]` (with - // description), 1 = `[[#anchor]]` (anchor only). - if link_format == 1 { - out.push_str("- [[#"); - out.push_str(anchor); - out.push_str("]]\n"); - } else { - out.push_str("- [[#"); - out.push_str(anchor); - out.push('|'); - out.push_str(title); - out.push_str("]]\n"); - } - } - out - } - - /// Build a flat list of wikilinks to every page (sorted), optionally - /// excluding `current_page`. - /// Map each indexed page name → its first heading's title (vimwiki's - /// caption for `generated_links_caption`). Pages with no heading are - /// omitted, so `build_links_text` falls back to `[[page]]` for them. - pub fn page_captions( - index: &crate::index::WorkspaceIndex, - ) -> std::collections::BTreeMap { - // Single pass over the page table — no per-name name→uri→page double - // lookup. The BTreeMap keeps the output name-sorted. - let mut map = std::collections::BTreeMap::new(); - for page in index.pages_by_uri.values() { - if let Some(h) = page.headings.first() { - map.insert(page.name.clone(), h.title.clone()); - } - } - map - } - - pub fn build_links_text( - pages: &[String], - heading_name: &str, - level: u8, - exclude: Option<&str>, - margin: usize, - captions: Option<&std::collections::BTreeMap>, - ) -> String { - let mut out = caption_line(heading_name, level); - let pad = " ".repeat(margin); - for name in pages { - if Some(name.as_str()) == exclude { - continue; - } - out.push_str(&pad); - out.push_str("- [["); - out.push_str(name); - // vimwiki `generated_links_caption`: when a caption map is supplied - // and the page has a first-heading, emit `[[page|Heading]]`. - if let Some(cap) = captions.and_then(|c| c.get(name)).filter(|c| !c.is_empty()) { - out.push('|'); - out.push_str(cap); - } - out.push_str("]]\n"); - } - out - } - - /// Locate an existing section whose level-1 heading matches - /// `heading_name` case-insensitively. Returns the offset range covering - /// `[heading start .. (immediately-following list end | heading end)]` - /// plus the start `AstPosition` for emitting an LSP range from a - /// synthesised span. - pub fn find_section_range( - ast: &DocumentNode, - heading_name: &str, - ) -> Option<(AstPosition, AstPosition)> { - let needle = heading_name.to_ascii_lowercase(); - for (i, block) in ast.children.iter().enumerate() { - let BlockNode::Heading(h) = block else { - continue; - }; - // Accept any heading level for section replacement. Previously only - // level‑1 headings were considered, which caused `VimwikiGenerateTagLinks` - // to miss existing tag sections if they were not level‑1 (e.g. `== Tag:`). - // Removing the level check makes the edit idempotent across heading - // levels. - // NOTE: All callers (TOC, Links, Tag links) expect a top‑level heading, - // but supporting any level is safe because headings are unique by name - // within a page and the operation replaces the whole section. - // If multiple headings share the same title at different levels, the - // first encountered will be replaced – this matches prior behaviour for - // level‑1. - // The level check is therefore removed. - // if h.level != 1 { - // continue; - // } - - let title = heading_title(h).to_ascii_lowercase(); - if title != needle { - continue; - } - let start = h.span.start; - // Determine the end of the section. Historically we only extended - // to the next immediate list block, which worked for simple tag - // sections like `= Tag: foo =` that contain a single list. For the - // full‑tags index (`= Tags =`) the section contains multiple - // sub‑headings and lists, so the next block after the heading is a - // level‑2 heading, not a list. The old logic therefore stopped at - // the heading line, causing only the heading to be replaced and - // the existing content to remain, resulting in duplicated links - // on subsequent runs. - // - // New logic walks forward until it finds the next heading at the - // same level as (or shallower than) the matched section heading — - // i.e. the next sibling/parent section — or reaches the end of the - // document. Using `<= section_level` (rather than a hard-coded 1) - // keeps deeper sub-headings (e.g. the `level+1` per-tag groups of a - // `Generated Tags` index) inside the replaced span at any configured - // `*_header_level`. The span end is the start of that next heading - // (exclusive) or the end of the last block. - let section_level = h.level; - let mut end = h.span.end; - for next_block in ast.children.iter().skip(i + 1) { - if let BlockNode::Heading(next_h) = next_block { - if next_h.level <= section_level { - end = next_h.span.start; - break; - } - } - // Otherwise extend the end to the end of this block. - end = match next_block { - BlockNode::Heading(h) => h.span.end, - BlockNode::Paragraph(p) => p.span.end, - BlockNode::HorizontalRule(hr) => hr.span.end, - BlockNode::Blockquote(bq) => bq.span.end, - BlockNode::Preformatted(pf) => pf.span.end, - BlockNode::MathBlock(mb) => mb.span.end, - BlockNode::List(list) => list.span.end, - BlockNode::DefinitionList(dl) => dl.span.end, - BlockNode::Table(t) => t.span.end, - BlockNode::Comment(c) => c.span.end, - BlockNode::Tag(tag) => tag.span.end, - BlockNode::Error(err) => err.span.end, - }; - } - return Some((start, end)); - } - None - } - - fn heading_title(h: &nuwiki_core::ast::HeadingNode) -> String { - crate::diagnostics::heading_text(&h.children) - } - - fn collect_toc_items(ast: &DocumentNode, toc_heading: &str) -> Vec { - let mut out = Vec::new(); - let needle = toc_heading.to_ascii_lowercase(); - for block in &ast.children { - let BlockNode::Heading(h) = block else { - continue; - }; - let title = heading_title(h); - if h.level == 1 && title.to_ascii_lowercase() == needle { - // skip the TOC's own heading - continue; - } - // Anchor is the raw heading text (vimwiki scheme) so the generated - // `[[#anchor]]` link matches the heading `id` the HTML renderer - // emits on export. In-editor navigation slugifies both sides, so it - // resolves regardless. - out.push(TocItem { - level: h.level, - anchor: title.clone(), - title, - }); - } - out - } - - /// Produce a `WorkspaceEdit` that replaces or inserts the TOC for the - /// given document. Returns `None` if the document has no headings at - /// all (other than possibly a pre-existing TOC heading). - /// - /// When there's no existing TOC section, a fresh one is inserted at - /// `insert_line` (0-based) — the cursor line passed by the client — or at - /// the top of the file when `None`. An existing TOC is always replaced in - /// place, regardless of the cursor. - #[allow(clippy::too_many_arguments)] - pub fn toc_edit( - text: &str, - ast: &DocumentNode, - uri: &Url, - utf8: bool, - heading: &str, - level: u8, - margin: usize, - link_format: u8, - insert_line: Option, - ) -> Option { - let items = collect_toc_items(ast, heading); - if items.is_empty() { - return None; - } - let triples: Vec<(u8, String, String)> = items - .into_iter() - .map(|it| (it.level, it.title, it.anchor)) - .collect(); - let new_text = build_toc_text(&triples, heading, level, margin, link_format); - let mut b = WorkspaceEditBuilder::new(); - match find_section_range(ast, heading) { - Some((start, end)) => { - let span = Span::new(start, end); - b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8)); - } - None => { - // Fresh TOC: at the cursor line, else the top of the file. - let top = LspPosition { - line: 0, - character: 0, - }; - let (pos, block) = section_insert(text, insert_line, top, &new_text); - b.edit(uri.clone(), text_edit_insert(pos, block)); - } - } - Some(b.build()) - } - - /// Like [`toc_edit`], but only when the page *already* has a - /// `= Contents =` section — used by the `auto_toc`-on-save hook so we - /// rebuild an existing TOC without inserting one into pages that never - /// had one. - #[allow(clippy::too_many_arguments)] - pub fn toc_rebuild_edit( - text: &str, - ast: &DocumentNode, - uri: &Url, - utf8: bool, - heading: &str, - level: u8, - margin: usize, - link_format: u8, - ) -> Option { - find_section_range(ast, heading)?; - // Rebuild-on-save only ever replaces an existing TOC in place, so the - // insert line is irrelevant. - toc_edit( - text, - ast, - uri, - utf8, - heading, - level, - margin, - link_format, - None, - ) - } - - /// Produce a `WorkspaceEdit` that replaces or inserts the - /// auto-generated links section for the given document. - #[allow(clippy::too_many_arguments)] - pub fn links_edit( - text: &str, - ast: &DocumentNode, - uri: &Url, - current_page: &str, - all_pages: &[String], - utf8: bool, - heading: &str, - level: u8, - margin: usize, - captions: Option<&std::collections::BTreeMap>, - insert_line: Option, - ) -> Option { - if all_pages.is_empty() { - return None; - } - let new_text = build_links_text( - all_pages, - heading, - level, - Some(current_page), - margin, - captions, - ); - let mut b = WorkspaceEditBuilder::new(); - match find_section_range(ast, heading) { - Some((start, end)) => { - let span = Span::new(start, end); - b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8)); - } - None => { - // Fresh section: at the cursor line, else the end of the file. - let (pos, block) = section_insert(text, insert_line, eof_position(text), &new_text); - b.edit(uri.clone(), text_edit_insert(pos, block)); - } - } - Some(b.build()) - } - - /// Like [`links_edit`], but only when a `Generated Links` section already - /// exists — the `auto_generate_links`-on-save hook, mirroring - /// [`toc_rebuild_edit`]. Never inserts one into a page that lacks it. - #[allow(clippy::too_many_arguments)] - pub fn links_rebuild_edit( - text: &str, - ast: &DocumentNode, - uri: &Url, - current_page: &str, - all_pages: &[String], - utf8: bool, - heading: &str, - level: u8, - margin: usize, - captions: Option<&std::collections::BTreeMap>, - ) -> Option { - find_section_range(ast, heading)?; - // Rebuild-on-save only ever replaces an existing section in place. - links_edit( - text, - ast, - uri, - current_page, - all_pages, - utf8, - heading, - level, - margin, - captions, - None, - ) - } - - // ===== Workspace queries ===== - - #[derive(Debug, Serialize)] - pub struct BrokenLinkEntry { - pub uri: Url, - pub range: tower_lsp::lsp_types::Range, - pub kind: &'static str, - pub message: String, - } - - /// Walk every indexed page and classify each outgoing link against the - /// workspace index. Open documents (held in `backend.documents`) use - /// live text for LSP range conversion; closed pages fall back to the - /// stored span coordinates verbatim (they're already in line/column form). - pub(crate) fn collect_workspace_broken_links( - backend: &Backend, - index: &WorkspaceIndex, - utf8: bool, - ) -> Vec { - let mut out = Vec::new(); - for page in index.pages_by_uri.values() { - for link in &page.outgoing { - let Some(broken) = classify_outgoing(link, &page.uri, index, &page.name) else { - continue; - }; - let range = match backend.documents.get(&page.uri) { - Some(doc) => crate::to_lsp_range(&link.span, &doc.text, utf8), - None => no_text_range(&link.span), - }; - out.push(BrokenLinkEntry { - uri: page.uri.clone(), - range, - kind: broken.tag(), - message: broken.message(), - }); - } - } - out.sort_by(|a, b| { - (a.uri.as_str(), a.range.start.line, a.range.start.character).cmp(&( - b.uri.as_str(), - b.range.start.line, - b.range.start.character, - )) - }); - out - } - - #[derive(Debug, Serialize)] - pub struct OrphanEntry { - pub uri: Url, - pub name: String, - } - - /// A page is an orphan when no other indexed page links to it. The - /// current convention is to surface every orphan and let the client - /// filter out the index page if it wants to. - pub fn find_orphans(index: &WorkspaceIndex) -> Vec { - let mut out: Vec = index - .pages_by_uri - .values() - .filter(|p| index.backlinks_for(&p.name).is_empty()) - .map(|p| OrphanEntry { - uri: p.uri.clone(), - name: p.name.clone(), - }) - .collect(); - out.sort_by(|a, b| a.name.cmp(&b.name)); - out - } - - fn no_text_range(span: &Span) -> tower_lsp::lsp_types::Range { - tower_lsp::lsp_types::Range { - start: LspPosition { - line: span.start.line, - character: span.start.column, - }, - end: LspPosition { - line: span.end.line, - character: span.end.column, - }, - } - } - - /// Re-exported AST link walker so tests can drive it without depending - /// on the diagnostics module's internals. - pub use crate::diagnostics::collect_wiki_links; - - // ===== Cluster A: list rewriters ===== - - use crate::edits::text_edit_delete; - use nuwiki_core::ast::ListSymbol; - - /// Parse a `:VimwikiListChangeSymbol` argument into a `ListSymbol`. - /// Accepts both the short markers (`-`, `*`, `#`, `1.`, …) and the - /// long variant names so wrappers can pass either shape. - pub fn parse_symbol(name: &str) -> Option { - match name { - "-" | "Dash" | "dash" => Some(ListSymbol::Dash), - "*" | "Star" | "star" => Some(ListSymbol::Star), - "#" | "Hash" | "hash" => Some(ListSymbol::Hash), - "1." | "Numeric" | "numeric" => Some(ListSymbol::Numeric), - "1)" | "NumericParen" | "numeric_paren" => Some(ListSymbol::NumericParen), - "a)" | "AlphaParen" | "alpha_paren" => Some(ListSymbol::AlphaParen), - "A)" | "AlphaUpperParen" | "alpha_upper_paren" => Some(ListSymbol::AlphaUpperParen), - "i)" | "RomanParen" | "roman_paren" => Some(ListSymbol::RomanParen), - "I)" | "RomanUpperParen" | "roman_upper_paren" => Some(ListSymbol::RomanUpperParen), - _ => None, - } - } - - /// Render a list marker for the given symbol + 1-based sequence index. - /// Indices only matter for ordered variants (`Numeric`, `AlphaParen`, …). - pub fn render_marker(symbol: ListSymbol, idx: usize) -> String { - match symbol { - ListSymbol::Dash => "-".to_string(), - ListSymbol::Star => "*".to_string(), - ListSymbol::Hash => "#".to_string(), - ListSymbol::Numeric => format!("{idx}."), - ListSymbol::NumericParen => format!("{idx})"), - ListSymbol::AlphaParen => alpha_marker(idx, false), - ListSymbol::AlphaUpperParen => alpha_marker(idx, true), - ListSymbol::RomanParen => format!("{})", roman_marker(idx, false)), - ListSymbol::RomanUpperParen => format!("{})", roman_marker(idx, true)), - } - } - - fn alpha_marker(idx: usize, upper: bool) -> String { - // Spreadsheet-style: 1 → a, 2 → b, …, 26 → z, 27 → aa. - let mut n = idx; - let mut buf = Vec::new(); - let base = if upper { b'A' } else { b'a' }; - while n > 0 { - let rem = (n - 1) % 26; - buf.push(base + rem as u8); - n = (n - 1) / 26; - } - let mut s: String = buf.iter().rev().map(|b| *b as char).collect(); - s.push(')'); - s - } - - fn roman_marker(idx: usize, upper: bool) -> String { - let map: [(usize, &str); 13] = [ - (1000, "m"), - (900, "cm"), - (500, "d"), - (400, "cd"), - (100, "c"), - (90, "xc"), - (50, "l"), - (40, "xl"), - (10, "x"), - (9, "ix"), - (5, "v"), - (4, "iv"), - (1, "i"), - ]; - let mut n = idx; - let mut out = String::new(); - for (val, sym) in map { - while n >= val { - out.push_str(sym); - n -= val; - } - } - if upper { - out.to_ascii_uppercase() - } else { - out - } - } - - /// Walk every list block at top level (recursing into blockquotes - /// and sublists) and call `f` with each item. - fn walk_list_items<'a, F: FnMut(&'a ListItemNode)>(blocks: &'a [BlockNode], f: &mut F) { - for block in blocks { - walk_list_items_block(block, f); - } - } - - fn walk_list_items_block<'a, F: FnMut(&'a ListItemNode)>(block: &'a BlockNode, f: &mut F) { - match block { - BlockNode::List(ListNode { items, .. }) => { - for item in items { - walk_list_items_in_item(item, f); - } - } - BlockNode::Blockquote(BlockquoteNode { children, .. }) => { - for c in children { - walk_list_items_block(c, f); - } - } - _ => {} - } - } - - fn walk_list_items_in_item<'a, F: FnMut(&'a ListItemNode)>(item: &'a ListItemNode, f: &mut F) { - f(item); - if let Some(sub) = &item.sublist { - for sub_it in &sub.items { - walk_list_items_in_item(sub_it, f); - } - } - } - - // ----- removeDone ----- - - /// Delete every checkbox item whose state is `Done` or `Rejected`, - /// cascading into their sublists. When `pos` is `Some`, restrict to - /// the contiguous list block containing `pos`'s line (the "current - /// list" — including its nested sublists). When `range` is `Some` - /// (`(line1, line2)`, 0-indexed inclusive), sweep the whole doc but keep - /// only items whose start line falls in that range — the vimwiki - /// `:'<,'>VimwikiRemoveDone` ranged form. With neither, sweep the whole - /// doc. `pos` and `range` are mutually exclusive (the client sets one). - pub fn remove_done_edit( - text: &str, - ast: &DocumentNode, - uri: &Url, - pos: Option, - range: Option<(u32, u32)>, - utf8: bool, - ) -> Option { - let mut victims: Vec = Vec::new(); - let mut collect = |item: &ListItemNode| { - if matches!( - item.checkbox, - Some(nuwiki_core::ast::CheckboxState::Done) - | Some(nuwiki_core::ast::CheckboxState::Rejected), - ) { - if let Some((l1, l2)) = range { - let line = item.span.start.line; - if line < l1 || line > l2 { - return; - } - } - victims.push(extend_to_line_end(text, item.span)); - } - }; - match pos { - None => walk_list_items(&ast.children, &mut collect), - Some(p) => { - // "Current list" = the top-level contiguous list block the - // cursor sits in. Walk just that block (and its sublists), - // leaving done items in sibling lists untouched. - let list = find_top_list_at_line(ast, p.line)?; - for item in &list.items { - walk_list_items_in_item(item, &mut collect); - } - } - } - if victims.is_empty() { - return None; - } - let mut b = WorkspaceEditBuilder::new(); - for span in victims { - b.edit(uri.clone(), text_edit_delete(span, text, utf8)); - } - Some(b.build()) - } - - /// Strip the `[ ]` checkbox (plus a single trailing space) from the - /// item at `line`, or from every item in the list when `whole_list` - /// is set. Leaves the marker and content intact — only the checkbox - /// disappears. - pub fn remove_checkbox_edit( - text: &str, - ast: &DocumentNode, - uri: &Url, - line: u32, - whole_list: bool, - utf8: bool, - ) -> Option { - let mut spans: Vec = Vec::new(); - if whole_list { - let list = find_list_at_line(ast, line)?; - collect_checkbox_spans(list, text, &mut spans); - } else { - let item = find_list_item_at(ast, line, 0)?; - if let Some(s) = remove_checkbox_span(text, item.span) { - spans.push(s); - } - } - if spans.is_empty() { - return None; - } - let mut b = WorkspaceEditBuilder::new(); - for span in spans { - b.edit(uri.clone(), text_edit_delete(span, text, utf8)); - } - Some(b.build()) - } - - fn collect_checkbox_spans(list: &ListNode, text: &str, spans: &mut Vec) { - for item in &list.items { - if let Some(s) = remove_checkbox_span(text, item.span) { - spans.push(s); - } - if let Some(sub) = &item.sublist { - collect_checkbox_spans(sub, text, spans); - } - } - } - - /// Span of the `[ ]` checkbox plus one trailing space (so removal - /// leaves `* foo`, not `* foo`). `None` when the item has no checkbox. - fn remove_checkbox_span(text: &str, item_span: Span) -> Option { - let cb = find_checkbox_span(text, item_span)?; - let bytes = text.as_bytes(); - let mut end_off = cb.end.offset; - let mut end_col = cb.end.column; - if end_off < bytes.len() && bytes[end_off] == b' ' { - end_off += 1; - end_col += 1; - } - Some(Span::new( - cb.start, - AstPosition { - line: cb.end.line, - column: end_col, - offset: end_off, - }, - )) - } - - /// Find the top-level (contiguous) list block whose span contains - /// `line`. Unlike [`find_list_at_line`], this does not descend into - /// sublists — it returns the outermost list so callers operate on the - /// whole list block the cursor belongs to. - fn find_top_list_at_line(ast: &DocumentNode, line: u32) -> Option<&ListNode> { - for block in &ast.children { - if let Some(list) = find_top_list_in_block(block, line) { - return Some(list); - } - } - None - } - - fn find_top_list_in_block(block: &BlockNode, line: u32) -> Option<&ListNode> { - match block { - BlockNode::List(list) => { - if (list.span.start.line..=list.span.end.line).contains(&line) { - Some(list) - } else { - None - } - } - BlockNode::Blockquote(BlockquoteNode { children, .. }) => { - for c in children { - if let Some(l) = find_top_list_in_block(c, line) { - return Some(l); - } - } - None - } - _ => None, - } - } - - /// Extend a span to include the trailing newline (so deletion removes - /// the empty line that would otherwise be left behind). - fn extend_to_line_end(text: &str, span: Span) -> Span { - let bytes = text.as_bytes(); - let mut end_off = span.end.offset.min(bytes.len()); - let mut end_line = span.end.line; - let mut end_col = span.end.column; - if end_off < bytes.len() && bytes[end_off] == b'\n' { - end_off += 1; - end_line += 1; - end_col = 0; - } - Span::new( - span.start, - AstPosition { - line: end_line, - column: end_col, - offset: end_off, - }, - ) - } - - // ----- renumber ----- - - /// Re-sequence numeric markers in the list containing `line`, or in - /// every list when `whole_file` is set. - pub fn renumber_edit( - text: &str, - ast: &DocumentNode, - uri: &Url, - line: u32, - whole_file: bool, - utf8: bool, - ) -> Option { - let mut edits: Vec<(Span, String)> = Vec::new(); - if whole_file { - for block in &ast.children { - renumber_in_block(block, text, &mut edits); - } - } else { - let list = find_list_at_line(ast, line)?; - renumber_in_list(list, text, &mut edits); - } - if edits.is_empty() { - return None; - } - let mut b = WorkspaceEditBuilder::new(); - for (span, new) in edits { - b.edit(uri.clone(), text_edit_replace(span, new, text, utf8)); - } - Some(b.build()) - } - - fn renumber_in_block(block: &BlockNode, text: &str, edits: &mut Vec<(Span, String)>) { - match block { - BlockNode::List(list) => renumber_in_list(list, text, edits), - BlockNode::Blockquote(BlockquoteNode { children, .. }) => { - for c in children { - renumber_in_block(c, text, edits); - } - } - _ => {} - } - } - - fn renumber_in_list(list: &ListNode, text: &str, edits: &mut Vec<(Span, String)>) { - for (idx, item) in list.items.iter().enumerate() { - if is_ordered_symbol(item.symbol) { - if let Some((span, _, _)) = find_marker_span(text, item.span) { - let new = render_marker(item.symbol, idx + 1); - edits.push((span, new)); - } - } - if let Some(sub) = &item.sublist { - renumber_in_list(sub, text, edits); - } - } - } - - fn is_ordered_symbol(s: ListSymbol) -> bool { - matches!( - s, - ListSymbol::Numeric - | ListSymbol::NumericParen - | ListSymbol::AlphaParen - | ListSymbol::AlphaUpperParen - | ListSymbol::RomanParen - | ListSymbol::RomanUpperParen - ) - } - - // ----- changeSymbol ----- - - /// Rewrite the leading marker on each list item. When `whole_list` - /// is true, every item in the same list (recursively through - /// sublists) gets the new symbol; otherwise only the item at `line` - /// is touched. - pub fn change_symbol_edit( - text: &str, - ast: &DocumentNode, - uri: &Url, - line: u32, - target: ListSymbol, - whole_list: bool, - utf8: bool, - ) -> Option { - let mut edits: Vec<(Span, String)> = Vec::new(); - if whole_list { - let list = find_list_at_line(ast, line)?; - rewrite_list_symbols(list, target, text, &mut edits); - } else { - let item = find_list_item_at(ast, line, 0)?; - if let Some((span, _, _)) = find_marker_span(text, item.span) { - edits.push((span, render_marker(target, 1))); - } - } - if edits.is_empty() { - return None; - } - let mut b = WorkspaceEditBuilder::new(); - for (span, new) in edits { - b.edit(uri.clone(), text_edit_replace(span, new, text, utf8)); - } - Some(b.build()) - } - - fn rewrite_list_symbols( - list: &ListNode, - target: ListSymbol, - text: &str, - edits: &mut Vec<(Span, String)>, - ) { - for (idx, item) in list.items.iter().enumerate() { - if let Some((span, _, _)) = find_marker_span(text, item.span) { - edits.push((span, render_marker(target, idx + 1))); - } - if let Some(sub) = &item.sublist { - rewrite_list_symbols(sub, target, text, edits); - } - } - } - - // ----- changeLevel ----- - - /// Indent (`delta > 0`) or dedent (`delta < 0`) the item at `line`. - /// When `whole_subtree` is set, all descendants get the same shift - /// so the visual tree stays consistent. - #[allow(clippy::too_many_arguments)] - pub fn change_level_edit( - text: &str, - ast: &DocumentNode, - uri: &Url, - line: u32, - delta: i32, - whole_subtree: bool, - utf8: bool, - cycle_bullets: bool, - bullet_types: &[String], - ) -> Option { - let item = find_list_item_at(ast, line, 0)?; - let mut edits: Vec<(Span, String)> = Vec::new(); - if whole_subtree { - collect_level_edits(item, text, delta, &mut edits); - } else { - // Only the first line of the item — the marker line — - // changes indentation. Sublists keep their own indentation - // (a partial shift would break the tree). - push_level_edit_for_line(item.span.start.line, text, delta, &mut edits); - } - // vimwiki `cycle_bullets`: as an unordered item changes indent level, - // rotate its glyph through `bullet_types` (ring buffer keyed by depth). - if cycle_bullets && !bullet_types.is_empty() { - if let Some((mspan, marker, _)) = find_marker_span(text, item.span) { - if marker.chars().count() == 1 && bullet_types.iter().any(|b| b == marker) { - // The marker's column is the item's indentation width (the - // item span starts at line start). 2 cols per depth level. - let old_depth = (mspan.start.column / 2) as i32; - let new_depth = (old_depth + delta).max(0) as usize; - let glyph = &bullet_types[new_depth % bullet_types.len()]; - if glyph.as_str() != marker { - edits.push((mspan, glyph.clone())); - } - } - } - } - if edits.is_empty() { - return None; - } - let mut b = WorkspaceEditBuilder::new(); - for (span, new) in edits { - b.edit(uri.clone(), text_edit_replace(span, new, text, utf8)); - } - Some(b.build()) - } - - fn collect_level_edits( - item: &ListItemNode, - text: &str, - delta: i32, - edits: &mut Vec<(Span, String)>, - ) { - for line in item.span.start.line..=item.span.end.line { - push_level_edit_for_line(line, text, delta, edits); - } - if let Some(sub) = &item.sublist { - for sub_it in &sub.items { - // Already covered by the line range, but recurse so we - // don't miss items past the parent's span.end (resilient - // to off-by-one quirks in the lexer). - collect_level_edits(sub_it, text, delta, edits); - } - } - } - - fn push_level_edit_for_line( - line_num: u32, - text: &str, - delta: i32, - edits: &mut Vec<(Span, String)>, - ) { - let bytes = text.as_bytes(); - // Locate the start offset of `line_num`. - let mut off = 0usize; - let mut cur_line: u32 = 0; - while cur_line < line_num && off < bytes.len() { - if bytes[off] == b'\n' { - cur_line += 1; - } - off += 1; - } - if cur_line != line_num { - return; - } - let mut ws_end = off; - while ws_end < bytes.len() && (bytes[ws_end] == b' ' || bytes[ws_end] == b'\t') { - ws_end += 1; - } - let current_ws = ws_end - off; - let new_ws = if delta < 0 { - current_ws.saturating_sub((-delta * 2) as usize) - } else { - current_ws + (delta * 2) as usize - }; - if new_ws == current_ws { - return; - } - let span = Span::new( - AstPosition { - line: line_num, - column: 0, - offset: off, - }, - AstPosition { - line: line_num, - column: current_ws as u32, - offset: ws_end, - }, - ); - edits.push((span, " ".repeat(new_ws))); - } - - // ----- shared helpers ----- - - /// Find the deepest list containing `line` (an item's line range). - /// Top-level lists win over nested ones unless the cursor is on a - /// sublist item. - pub fn find_list_at_line(ast: &DocumentNode, line: u32) -> Option<&ListNode> { - for block in &ast.children { - if let Some(list) = find_list_in_block(block, line) { - return Some(list); - } - } - None - } - - fn find_list_in_block(block: &BlockNode, line: u32) -> Option<&ListNode> { - match block { - BlockNode::List(list) => { - // First check sublists for a tighter match. - for item in &list.items { - if let Some(sub) = &item.sublist { - if (sub.span.start.line..=sub.span.end.line).contains(&line) { - if let Some(nested) = find_list_in_sublist(sub, line) { - return Some(nested); - } - return Some(sub); - } - } - } - if (list.span.start.line..=list.span.end.line).contains(&line) { - Some(list) - } else { - None - } - } - BlockNode::Blockquote(BlockquoteNode { children, .. }) => { - for c in children { - if let Some(l) = find_list_in_block(c, line) { - return Some(l); - } - } - None - } - _ => None, - } - } - - fn find_list_in_sublist(list: &ListNode, line: u32) -> Option<&ListNode> { - for item in &list.items { - if let Some(sub) = &item.sublist { - if (sub.span.start.line..=sub.span.end.line).contains(&line) { - return find_list_in_sublist(sub, line).or(Some(sub)); - } - } - } - None - } - - /// Find the leading marker substring (e.g. `"-"`, `"1."`, `"a)"`) - /// within the first line of an item's source span. Returns the - /// span covering only the marker characters, the marker text, and - /// the byte offset right after the marker (caller uses this to - /// extend edits if needed). - pub fn find_marker_span(text: &str, item_span: Span) -> Option<(Span, &str, usize)> { - let start = item_span.start.offset; - let end = item_span.end.offset.min(text.len()); - if start >= end { - return None; - } - let bytes = text.as_bytes(); - // Skip leading whitespace. - let mut i = start; - while i < end && (bytes[i] == b' ' || bytes[i] == b'\t') { - i += 1; - } - let marker_start = i; - // Marker = run of non-space chars before the first whitespace. - // For `-`, `*`, `#`: single char. For `1.`, `1)`, `a)`, …: 2+. - while i < end && !(bytes[i] as char).is_whitespace() { - i += 1; - } - if marker_start == i { - return None; - } - let marker_str = std::str::from_utf8(&bytes[marker_start..i]).ok()?; - // Sanity: only accept characters that could form a list marker. - if !marker_str - .chars() - .all(|c| c.is_alphanumeric() || matches!(c, '-' | '*' | '#' | '.' | ')')) - { - return None; - } - let col_offset = marker_start - start; - let span = Span::new( - AstPosition { - line: item_span.start.line, - column: item_span.start.column + col_offset as u32, - offset: marker_start, - }, - AstPosition { - line: item_span.start.line, - column: item_span.start.column + (i - start) as u32, - offset: i, - }, - ); - Some((span, marker_str, i)) - } - - // ===== Cluster B: table rewriters ===== - - use nuwiki_core::ast::{TableNode, TableRowNode}; - - /// Plain-text body of a freshly-inserted vimwiki table with `cols` - /// columns and `rows` data rows (header + separator + rows). - pub fn render_blank_table(cols: usize, rows: usize) -> String { - let cell = " "; - let mut out = String::new(); - // Header row. - out.push('|'); - for _ in 0..cols { - out.push_str(cell); - out.push('|'); - } - out.push('\n'); - // Separator row. - out.push('|'); - for _ in 0..cols { - out.push_str("--"); - out.push('|'); - } - out.push('\n'); - // Data rows. - for _ in 0..rows { - out.push('|'); - for _ in 0..cols { - out.push_str(cell); - out.push('|'); - } - out.push('\n'); - } - out - } - - /// Reformat the table containing `line` so every column shares the - /// same width as its widest cell. Header-separator rows get - /// repadded with `-` runs so the table still parses cleanly. - pub fn table_align_edit( - text: &str, - ast: &DocumentNode, - uri: &Url, - line: u32, - utf8: bool, - reduce_last_col: bool, - ) -> Option { - let table = find_table_at_line(ast, line)?; - let rendered = render_aligned_table(table, text, reduce_last_col)?; - let span = table.span; - let mut b = WorkspaceEditBuilder::new(); - b.edit(uri.clone(), text_edit_replace(span, rendered, text, utf8)); - Some(b.build()) - } - - /// Swap the column under cursor with its left (`delta = -1`) or - /// right (`delta = +1`) neighbour across every row of the table. - #[allow(clippy::too_many_arguments)] - pub fn table_move_column_edit( - text: &str, - ast: &DocumentNode, - uri: &Url, - line: u32, - col: u32, - delta: i32, - utf8: bool, - reduce_last_col: bool, - ) -> Option { - let table = find_table_at_line(ast, line)?; - let cur_col = cell_column_at(table, text, line, col)?; - let other = cur_col as i32 + delta; - if other < 0 { - return None; - } - let other = other as usize; - // All rows must have both columns to make the swap meaningful. - let max_cols = table.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0); - if other >= max_cols { - return None; - } - let rendered = render_swapped_table(table, text, cur_col, other, reduce_last_col)?; - let mut b = WorkspaceEditBuilder::new(); - b.edit( - uri.clone(), - text_edit_replace(table.span, rendered, text, utf8), - ); - Some(b.build()) - } - - fn find_table_at_line(ast: &DocumentNode, line: u32) -> Option<&TableNode> { - for block in &ast.children { - if let BlockNode::Table(t) = block { - if (t.span.start.line..=t.span.end.line).contains(&line) { - return Some(t); - } - } - // Tables inside blockquotes are uncommon but possible. - if let BlockNode::Blockquote(BlockquoteNode { children, .. }) = block { - for c in children { - if let BlockNode::Table(t) = c { - if (t.span.start.line..=t.span.end.line).contains(&line) { - return Some(t); - } - } - } - } - } - None - } - - /// Locate the column index under (`line`, `col`) by walking the - /// row that contains `line` and counting pipe separators before - /// the cursor. - fn cell_column_at(table: &TableNode, text: &str, line: u32, col: u32) -> Option { - let row = table - .rows - .iter() - .find(|r| (r.span.start.line..=r.span.end.line).contains(&line))?; - let row_src = source_slice(text, row.span); - let bytes = row_src.as_bytes(); - // `col` is the cursor's byte column on `line`. Re-base it - // against the row's start column on that line. - let row_start_col = row.span.start.column; - if col < row_start_col { - return None; - } - let mut rel = (col - row_start_col) as usize; - rel = rel.min(bytes.len()); - let mut pipes_before = 0usize; - for (i, b) in bytes.iter().enumerate() { - if i >= rel { - break; - } - if *b == b'|' { - pipes_before += 1; - } - } - // First pipe opens the row, so column index is `pipes - 1`. - if pipes_before == 0 { - return Some(0); - } - Some(pipes_before - 1) - } - - fn source_slice(text: &str, span: Span) -> &str { - let start = span.start.offset.min(text.len()); - let end = span.end.offset.min(text.len()); - &text[start..end] - } - - /// Compute column widths (max content length across rows). - fn column_widths(table: &TableNode, text: &str, reduce_last_col: bool) -> Vec { - let max_cols = table.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0); - let mut widths = vec![1usize; max_cols]; - for row in &table.rows { - for (i, cell) in row.cells.iter().enumerate() { - let content = source_slice(text, cell.span).trim(); - if !is_separator_cell(content) { - widths[i] = widths[i].max(content.chars().count()); - } - } - } - // vimwiki `table_reduce_last_col`: keep the last column at its minimum - // so it isn't padded out to fill. - if reduce_last_col { - if let Some(last) = widths.last_mut() { - *last = 1; - } - } - widths - } - - fn is_separator_cell(content: &str) -> bool { - !content.is_empty() - && content - .chars() - .all(|c| c == '-' || c == ':' || c.is_whitespace()) - } - - fn render_aligned_table( - table: &TableNode, - text: &str, - reduce_last_col: bool, - ) -> Option { - let widths = column_widths(table, text, reduce_last_col); - let mut out = String::new(); - for (i, row) in table.rows.iter().enumerate() { - render_row(row, text, &widths, &mut out); - // The lexer dropped the `|---|---|` separator row, so - // re-emit one after the header when `has_header` is set. - if i == 0 && table.has_header { - emit_separator_row(&widths, &mut out); - } - } - Some(out) - } - - fn emit_separator_row(widths: &[usize], out: &mut String) { - out.push('|'); - for w in widths { - out.push_str(&"-".repeat(w + 2)); - out.push('|'); - } - out.push('\n'); - } - - fn render_row(row: &TableRowNode, text: &str, widths: &[usize], out: &mut String) { - let mut all_separator = !row.cells.is_empty(); - for cell in &row.cells { - let content = source_slice(text, cell.span).trim(); - if !is_separator_cell(content) { - all_separator = false; - break; - } - } - out.push('|'); - for (i, _cell) in row.cells.iter().enumerate() { - let width = *widths.get(i).unwrap_or(&1); - if all_separator { - out.push_str(&"-".repeat(width + 2)); - } else { - let content = source_slice(text, row.cells[i].span).trim(); - let pad = width.saturating_sub(content.chars().count()); - out.push(' '); - out.push_str(content); - out.push_str(&" ".repeat(pad)); - out.push(' '); - } - out.push('|'); - } - out.push('\n'); - } - - fn render_swapped_table( - table: &TableNode, - text: &str, - a: usize, - b: usize, - reduce_last_col: bool, - ) -> Option { - // Swap the column-width slots first so the rendered table - // remains visually aligned after the move. - let mut widths = column_widths(table, text, false); - if a < widths.len() && b < widths.len() { - widths.swap(a, b); - } - // Apply reduce_last_col AFTER the swap so the column physically last - // stays minimal (clamping before the swap would move the narrow slot). - if reduce_last_col { - if let Some(last) = widths.last_mut() { - *last = 1; - } - } - let mut out = String::new(); - for (row_idx, row) in table.rows.iter().enumerate() { - out.push('|'); - for i in 0..row.cells.len() { - let src_col = if i == a { - b - } else if i == b { - a - } else { - i - }; - let width = *widths.get(i).unwrap_or(&1); - let content = if src_col < row.cells.len() { - source_slice(text, row.cells[src_col].span).trim() - } else { - "" - }; - let pad = width.saturating_sub(content.chars().count()); - out.push(' '); - out.push_str(content); - out.push_str(&" ".repeat(pad)); - out.push(' '); - out.push('|'); - } - out.push('\n'); - // Re-emit the dropped separator row after the header. - if row_idx == 0 && table.has_header { - emit_separator_row(&widths, &mut out); - } - } - Some(out) - } - - // ===== diary helpers ===== - - use crate::edits::op_create; - - /// Build the `WorkspaceEdit` that materialises the diary index page. - /// - /// Decision tree: - /// - If the index URI is open in `backend.documents`, the live text is - /// the source of truth: emit a single full-document `TextEdit` - /// replacing it with `body`. - /// - If the index file exists on disk but isn't open: same full-doc - /// replace, span computed from the on-disk bytes. - /// - If neither: a `CreateFile` op + an insert at `0:0`. - pub(crate) fn diary_generate_index_edit( - backend: &Backend, - index_uri: &Url, - body: &str, - utf8: bool, - ) -> WorkspaceEdit { - let mut b = WorkspaceEditBuilder::new(); - if let Some(doc) = backend.documents.get(index_uri) { - let span = whole_doc_span(&doc.text); - b.edit( - index_uri.clone(), - text_edit_replace(span, body.to_string(), &doc.text, utf8), - ); - return b.build(); - } - if let Ok(path) = index_uri.to_file_path() { - if let Ok(existing) = std::fs::read_to_string(&path) { - let span = whole_doc_span(&existing); - b.edit( - index_uri.clone(), - text_edit_replace(span, body.to_string(), &existing, utf8), - ); - return b.build(); - } - } - // File does not exist — create it and insert the body. The text - // edit applies *after* CreateFile via the builder's ordering. - b.file_op(op_create(index_uri.clone())); - b.edit( - index_uri.clone(), - crate::edits::text_edit_insert( - LspPosition { - line: 0, - character: 0, - }, - body.to_string(), - ), - ); - b.build() - } - - // ===== Tag ops ===== - // - // Three tag-driven commands beyond the indexing itself. - // `tag_hits` powers `nuwiki.tags.search`; - // `tag_links_edit` powers `nuwiki.tags.generateLinks`; the rebuild - // command is a direct re-walk of the wiki root in the dispatcher. - - use nuwiki_core::ast::TagScope; - - /// One row returned by `nuwiki.tags.search`. Serialised as - /// `{ name, uri, range, scope, page }` so editors can render either a - /// flat hit list or a per-tag tree. - #[derive(Debug, Clone, serde::Serialize)] - pub struct TagHit { - pub name: String, - pub uri: Url, - pub range: tower_lsp::lsp_types::Range, - pub scope: &'static str, - pub page: String, - } - - fn scope_tag(s: TagScope) -> &'static str { - match s { - TagScope::File => "file", - TagScope::Heading(_) => "heading", - TagScope::Standalone => "standalone", - } - } - - /// Filter every indexed tag by a case-insensitive substring match. An - /// empty query returns everything (sorted by name, then by page). - pub fn tag_hits(index: &WorkspaceIndex, query: &str) -> Vec { - let needle = query.to_ascii_lowercase(); - let mut out: Vec = Vec::new(); - for (name, occs) in &index.tags_by_name { - if !needle.is_empty() && !name.to_ascii_lowercase().contains(&needle) { - continue; - } - for occ in occs { - let page = index - .pages_by_uri - .get(&occ.uri) - .map(|p| p.name.clone()) - .unwrap_or_default(); - out.push(TagHit { - name: name.clone(), - uri: occ.uri.clone(), - range: no_text_range(&occ.span), - scope: scope_tag(occ.scope), - page, - }); - } - } - out.sort_by(|a, b| { - a.name - .cmp(&b.name) - .then_with(|| a.page.cmp(&b.page)) - .then_with(|| a.range.start.line.cmp(&b.range.start.line)) - }); - out - } - - /// Snapshot of `tag → sorted [page names]` taken under the index read - /// lock so the dispatcher can release the lock before computing the - /// edit. - pub fn tag_pages_snapshot( - index: &WorkspaceIndex, - ) -> std::collections::BTreeMap> { - let mut by_tag: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - for (name, occs) in &index.tags_by_name { - for occ in occs { - if let Some(page) = index.pages_by_uri.get(&occ.uri) { - by_tag - .entry(name.clone()) - .or_default() - .insert(page.name.clone()); - } - } - } - by_tag - .into_iter() - .map(|(k, v)| (k, v.into_iter().collect())) - .collect() - } - - /// Render a `= Tag: =` section listing every page tagged with - /// `name`. When `tag` is `None` the section becomes a "Tag Index" - /// covering every tag, headed `== ==` per group — matches - /// `:VimwikiGenerateTagLinks` (no arg) variant. - pub fn build_tag_links_text( - pages_by_tag: &std::collections::BTreeMap>, - tag: Option<&str>, - header: &str, - level: u8, - margin: usize, - ) -> Option { - let pad = " ".repeat(margin); - match tag { - Some(t) => { - let pages = pages_by_tag.get(t)?; - if pages.is_empty() { - return None; - } - let mut out = caption_line(&format!("Tag: {t}"), level); - for p in pages { - out.push_str(&format!("{pad}- [[{p}]]\n")); - } - Some(out) - } - None => { - if pages_by_tag.is_empty() { - return None; - } - let mut out = caption_line(header, level); - let sub = level.saturating_add(1); - for (name, pages) in pages_by_tag { - out.push('\n'); - out.push_str(&caption_line(name, sub)); - for p in pages { - out.push_str(&format!("{pad}- [[{p}]]\n")); - } - } - Some(out) - } - } - } - - /// Heading name used to locate an existing tag-links section so - /// re-generation is idempotent. The no-arg index uses the configured - /// `tags_header`; a single-tag section uses `Tag: `. - fn tag_section_heading(tag: Option<&str>, header: &str) -> String { - match tag { - Some(t) => format!("Tag: {t}"), - None => header.to_string(), - } - } - - /// Build a `WorkspaceEdit` that replaces an existing `Tag: ` - /// section (case-insensitive match on the h1 title) or inserts at the - /// top of the document when no such section exists. Returns `None` - /// when there are no matching tagged pages. - #[allow(clippy::too_many_arguments)] - pub fn tag_links_edit( - text: &str, - ast: &DocumentNode, - uri: &Url, - tag: Option<&str>, - pages_by_tag: &std::collections::BTreeMap>, - utf8: bool, - header: &str, - level: u8, - margin: usize, - insert_line: Option, - ) -> Option { - let body = build_tag_links_text(pages_by_tag, tag, header, level, margin)?; - let heading = tag_section_heading(tag, header); - let mut b = WorkspaceEditBuilder::new(); - match find_section_range(ast, &heading) { - Some((start, end)) => { - let span = Span::new(start, end); - b.edit(uri.clone(), text_edit_replace(span, body, text, utf8)); - } - None => { - // Fresh section: at the cursor line, else the end of the file. - let (pos, block) = section_insert(text, insert_line, eof_position(text), &body); - b.edit(uri.clone(), text_edit_insert(pos, block)); - } - } - Some(b.build()) - } - - /// Like [`tag_links_edit`] for the no-arg index, but only when a - /// `Generated Tags` section already exists — the `auto_generate_tags` - /// on-save hook. Never inserts one into a page that lacks it. - #[allow(clippy::too_many_arguments)] - pub fn tag_links_rebuild_edit( - text: &str, - ast: &DocumentNode, - uri: &Url, - pages_by_tag: &std::collections::BTreeMap>, - utf8: bool, - header: &str, - level: u8, - margin: usize, - ) -> Option { - find_section_range(ast, header)?; - // Rebuild-on-save only ever replaces an existing section in place. - tag_links_edit( - text, - ast, - uri, - None, - pages_by_tag, - utf8, - header, - level, - margin, - None, - ) - } - - fn whole_doc_span(text: &str) -> Span { - let bytes = text.as_bytes(); - let mut line = 0u32; - let mut last_nl = 0usize; - for (i, b) in bytes.iter().enumerate() { - if *b == b'\n' { - line += 1; - last_nl = i + 1; - } - } - let column = (bytes.len() - last_nl) as u32; - Span::new( - AstPosition { - line: 0, - column: 0, - offset: 0, - }, - AstPosition { - line, - column, - offset: bytes.len(), - }, - ) - } -} - -/// Disk-touching helpers for the `nuwiki.export.*` commands. -/// Kept out of `ops` because everything here is side-effecting — pure -/// rendering / templating lives in [`crate::export`]. -pub mod export_ops { - use std::path::PathBuf; - - use nuwiki_core::ast::DocumentNode; - use nuwiki_core::date::DiaryDate; - - use crate::config::WikiConfig; - use crate::diary::DiaryEntry; - use crate::export::{ - css_path, fallback_template, is_excluded, output_path_for, render_page_html, template_for, - DEFAULT_CSS, - }; - - /// Per-page result of `write_page`. Serialised back to the client as - /// `{ uri, output_path, page }`. - pub struct PageExport { - pub page: String, - pub output_path: PathBuf, - pub source_uri: Option, - } - - impl PageExport { - pub fn to_json(&self) -> serde_json::Value { - serde_json::json!({ - "page": self.page, - "output_path": self.output_path, - "uri": self.source_uri, - }) - } - } - - /// CSS write outcome — `created = true` when we just put a default - /// stylesheet on disk; false when one already existed. - pub struct CssOutcome { - pub path: PathBuf, - pub created: bool, - } - - /// Render `doc` and write the result to `/.html`. - /// Reads the template from disk (primary → fallback → built-in - /// fallback). Creates any missing parent directories. - pub fn write_page( - doc: &DocumentNode, - name: &str, - cfg: &WikiConfig, - force: bool, - ) -> std::io::Result { - if is_excluded(&cfg.html.exclude_files, name) { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("excluded by exclude_files: {name}"), - )); - } - // When a `custom_wiki2html` converter is configured, hand the page off - // to it instead of the built-in renderer (vimwiki parity). - if !cfg.html.custom_wiki2html.trim().is_empty() { - return run_custom_wiki2html(name, cfg, force); - } - let template_source = template_for(&cfg.html, doc); - let template = load_template(&template_source) - .unwrap_or_else(|| fallback_template(&cfg.html.css_name)); - let html = render_page_html( - doc, - Some(template), - name, - DiaryDate::today_utc(), - &cfg.html, - Some(cfg.file_extension.as_str()), - std::collections::HashMap::new(), - )?; - let out_path = output_path_for(&cfg.html, name); - if let Some(parent) = out_path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&out_path, html)?; - let source_uri = tower_lsp::lsp_types::Url::from_file_path( - cfg.root.join(format!("{name}{}", cfg.file_extension)), - ) - .ok(); - Ok(PageExport { - page: name.to_string(), - output_path: out_path, - source_uri, - }) - } - - /// `sh`-quote a path/string for the `custom_wiki2html` command line. - fn sh_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) - } - - /// Run an external `custom_wiki2html` converter for `name`, mirroring - /// vimwiki's argument order: - /// script force syntax ext output_dir input_file css_file - /// template_path template_default template_ext root_path args - /// Empty optional args are passed as `-` (vimwiki convention). The script - /// is responsible for writing the output file; we return the path it is - /// expected to produce (`output_path_for`). - fn run_custom_wiki2html( - name: &str, - cfg: &WikiConfig, - force: bool, - ) -> std::io::Result { - let h = &cfg.html; - let input = cfg.root.join(format!("{name}{}", cfg.file_extension)); - std::fs::create_dir_all(&h.html_path)?; - let css = crate::export::css_path(h); - let ext = cfg.file_extension.trim_start_matches('.'); - let dash = |s: &str| { - if s.is_empty() { - "-".to_string() - } else { - s.to_string() - } - }; - let tpl_path = if h.template_path.as_os_str().is_empty() { - "-".to_string() - } else { - sh_quote(&h.template_path.display().to_string()) - }; - let root_path = { - let rp = crate::export::compute_root_path(name); - if rp.is_empty() { - "-".to_string() - } else { - sh_quote(&rp) - } - }; - let cmd = format!( - "{} {} {} {} {} {} {} {} {} {} {} {}", - h.custom_wiki2html, - if force { 1 } else { 0 }, - cfg.syntax, - ext, - sh_quote(&h.html_path.display().to_string()), - sh_quote(&input.display().to_string()), - sh_quote(&css.display().to_string()), - tpl_path, - dash(&h.template_default), - dash(&h.template_ext), - root_path, - dash(h.custom_wiki2html_args.trim()), - ); - let status = std::process::Command::new("sh") - .arg("-c") - .arg(&cmd) - .status() - .map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("custom_wiki2html spawn failed: {e}"), - ) - })?; - if !status.success() { - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!("custom_wiki2html exited with {status}"), - )); - } - Ok(PageExport { - page: name.to_string(), - output_path: output_path_for(&cfg.html, name), - source_uri: tower_lsp::lsp_types::Url::from_file_path(&input).ok(), - }) - } - - /// Two-tier template load. Returns the resolved template body, or - /// `None` if neither file exists — caller falls back to the built-in - /// template. - fn load_template(src: &crate::export::TemplateSource) -> Option { - if let Some(p) = src.primary.as_ref() { - if let Ok(text) = std::fs::read_to_string(p) { - return Some(text); - } - } - std::fs::read_to_string(&src.fallback).ok() - } - - /// Ensure `/` exists. Writes the built-in - /// [`DEFAULT_CSS`] when missing. No-op when it's already there. - pub fn ensure_css(cfg: &WikiConfig) -> std::io::Result { - let path = css_path(&cfg.html); - if path.exists() { - return Ok(CssOutcome { - path, - created: false, - }); - } - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&path, DEFAULT_CSS)?; - Ok(CssOutcome { - path, - created: true, - }) - } - - /// Write `/` listing the wiki's diary entries - /// newest-first, capped by `rss_max_items`. Emits an RSS 2.0 feed with - /// upstream's structure: `xmlns:atom`, channel `` → the diary index - /// page, an ``, channel + item ``, per-item - /// `` (bare date), and a `` - /// rendered-HTML body per entry. - pub fn write_rss(cfg: &WikiConfig, entries: &[DiaryEntry]) -> std::io::Result { - let mut sorted: Vec<&DiaryEntry> = entries.iter().collect(); - sorted.sort_by(|a, b| b.date.cmp(&a.date)); - let cap = if cfg.html.rss_max_items == 0 { - usize::MAX - } else { - cfg.html.rss_max_items - }; - let base_url = cfg.html.base_url.trim(); - let diary_rel = cfg.diary_rel_path.trim_matches('/'); - - let mut xml = String::from( - "\n\ -\n", - ); - xml.push_str(&format!("{}\n", xml_escape(&cfg.name))); - // Channel link points at the diary index page (upstream), not bare base. - if !base_url.is_empty() { - let index_link = format!("{base_url}{diary_rel}/{}.html", cfg.diary_index); - xml.push_str(&format!("{}\n", xml_escape(&index_link))); - xml.push_str(&format!( - "\n", - xml_escape(base_url), - xml_escape(&cfg.html.rss_name) - )); - } - xml.push_str("nuwiki diary\n"); - if let Some(first) = sorted.first() { - xml.push_str(&format!("{}\n", rfc822(&first.date))); - } - for e in sorted.into_iter().take(cap) { - let link = if base_url.is_empty() { - e.uri.as_str().to_string() - } else { - format!("{base_url}{diary_rel}/{}.html", e.date.format()) - }; - xml.push_str(""); - xml.push_str(&format!("{}", e.date.format())); - xml.push_str(&format!("{}", xml_escape(&link))); - xml.push_str(&format!( - "{}", - xml_escape(&e.date.format()) - )); - xml.push_str(&format!("{}", rfc822(&e.date))); - let body = render_entry_body(cfg, e); - if !body.is_empty() { - xml.push_str(&format!( - "", - cdata_safe(&body) - )); - } - xml.push_str("\n"); - } - xml.push_str("\n"); - - let path = rss_path(cfg); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&path, xml)?; - Ok(path) - } - - /// Render a diary entry's page body (no template) for the RSS CDATA block, - /// honouring the wiki's colour/valid-tag/emoji HTML options. Returns an - /// empty string when the file can't be read. - fn render_entry_body(cfg: &WikiConfig, e: &DiaryEntry) -> String { - use nuwiki_core::render::{HtmlRenderer, Renderer}; - use nuwiki_core::syntax::{vimwiki::VimwikiSyntax, SyntaxPlugin}; - let Ok(path) = e.uri.to_file_path() else { - return String::new(); - }; - let Ok(src) = std::fs::read_to_string(&path) else { - return String::new(); - }; - let ast = VimwikiSyntax::new().parse(&src); - let mut r = HtmlRenderer::new(); - if !cfg.html.color_dic.is_empty() { - r = r.with_colors(cfg.html.color_dic.clone()); - } - if !cfg.html.valid_html_tags.is_empty() { - r = r.with_valid_html_tags(cfg.html.valid_html_tags.clone()); - } - r = r.with_emoji(cfg.html.emoji_enable); - r.render_to_string(&ast).unwrap_or_default() - } - - /// Make rendered HTML safe to embed inside a `` block by - /// splitting any literal `]]>` sequence. - fn cdata_safe(s: &str) -> String { - s.replace("]]>", "]]]]>") - } - - /// RFC-822 date for a diary date at midnight UTC (1970-01-01 was a - /// Thursday). Used for RSS ``. - fn rfc822(d: &nuwiki_core::date::DiaryDate) -> String { - const WD: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - const MO: [&str; 12] = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - ]; - let days = d.to_days_epoch(); - let wd = (((days % 7) + 4) % 7 + 7) % 7; // 1970-01-01 = Thursday (4) - format!( - "{}, {:02} {} {} 00:00:00 +0000", - WD[wd as usize], - d.day, - MO[(d.month.clamp(1, 12) - 1) as usize], - d.year - ) - } - - /// Delete HTML files under `html_path` that have no corresponding wiki - /// source (vimwiki's `allToHtml` cleanup), skipping the CSS file and any - /// basename listed in `user_htmls`. Returns the pruned paths. Gated by the - /// caller on `!html_filename_parameterization` (slugified names can't be - /// reverse-mapped to a source). - pub fn prune_orphan_html(cfg: &WikiConfig) -> Vec { - let html_root = &cfg.html.html_path; - let css = css_path(&cfg.html); - let ext = cfg.file_extension.trim_start_matches('.'); - let mut pruned = Vec::new(); - let mut stack = vec![html_root.clone()]; - while let Some(dir) = stack.pop() { - let Ok(rd) = std::fs::read_dir(&dir) else { - continue; - }; - for entry in rd.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - continue; - } - if path.extension().and_then(|e| e.to_str()) != Some("html") { - continue; - } - if path == css { - continue; - } - let base = path.file_name().and_then(|s| s.to_str()).unwrap_or(""); - if cfg.html.user_htmls.iter().any(|u| u == base) { - continue; - } - // Map /.html back to /.. - let Ok(rel) = path.strip_prefix(html_root) else { - continue; - }; - let mut src = cfg.root.join(rel); - src.set_extension(ext); - if !src.exists() && std::fs::remove_file(&path).is_ok() { - pruned.push(path); - } - } - } - pruned - } - - fn rss_path(cfg: &WikiConfig) -> PathBuf { - let name = if cfg.html.rss_name.trim().is_empty() { - "rss.xml" - } else { - cfg.html.rss_name.trim() - }; - cfg.html.html_path.join(name) - } - - fn xml_escape(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for ch in s.chars() { - match ch { - '&' => out.push_str("&"), - '<' => out.push_str("<"), - '>' => out.push_str(">"), - '"' => out.push_str("""), - _ => out.push(ch), - } - } - out - } -} - -#[cfg(test)] -mod scope_tests { - use super::page_in_scope; - - #[test] - fn empty_scope_matches_everything() { - assert!(page_in_scope("anything", "")); - assert!(page_in_scope("a/b/c", "/")); - } - - #[test] - fn subtree_matches_self_and_descendants() { - assert!(page_in_scope("projects", "projects")); - assert!(page_in_scope("projects/api", "projects")); - assert!(page_in_scope("projects/api/v2", "projects")); - // Not a sibling that merely shares a prefix. - assert!(!page_in_scope("projectsX", "projects")); - assert!(!page_in_scope("Home", "projects")); - // Trailing slash on the scope is tolerated. - assert!(page_in_scope("projects/api", "projects/")); - } - - #[test] - fn glob_scope_uses_pattern_match() { - assert!(page_in_scope("diary/2026-05-30", "diary/*")); - assert!(page_in_scope("notes-2026", "notes-*")); - assert!(!page_in_scope("Home", "diary/*")); - } -} diff --git a/crates/nuwiki-lsp/src/config.rs b/crates/nuwiki-lsp/src/config.rs deleted file mode 100644 index b88c92c..0000000 --- a/crates/nuwiki-lsp/src/config.rs +++ /dev/null @@ -1,1098 +0,0 @@ -//! Server-side runtime config. -//! -//! Populated from `InitializeParams.initialization_options` at boot and -//! refreshed via `workspace/didChangeConfiguration`. Legacy single-wiki -//! shapes are accepted and desugared into the `wikis = [...]` form so -//! existing setups keep working. -//! -//! `WikiConfig` (diary path, html path, template options, …) and the -//! top-level `Config` (diagnostic severity, mappings opt-in, …) carry the -//! fields actually consumed by the server. - -use std::path::{Path, PathBuf}; - -use serde::Deserialize; -use tower_lsp::lsp_types::InitializeParams; - -#[derive(Debug, Clone, Default)] -pub struct Config { - pub wikis: Vec, - pub diagnostic: DiagnosticConfig, -} - -#[derive(Debug, Clone)] -pub struct WikiConfig { - pub name: String, - pub root: PathBuf, - pub file_extension: String, - pub syntax: String, - /// Stem of the wiki's index page (vimwiki's `index`, default - /// `"index"`). `/` is what - /// `:VimwikiIndex` opens. - pub index: String, - /// Subdirectory under `root` where diary entries live. Matches - /// vimwiki's `g:vimwiki_diary_rel_path` (default `"diary"`). - pub diary_rel_path: String, - /// Stem of the diary index page within the diary subdirectory. - /// Matches vimwiki's `g:vimwiki_diary_index` (default `"diary"`). - /// The full path is `//.wiki`. - pub diary_index: String, - /// `daily`/`weekly`/`monthly`/`yearly`. Picks the date format the - /// diary commands use. `daily` is supported; the others fall through - /// to vimwiki-style names but the commands are no-ops for them. - pub diary_frequency: String, - /// Weekly-diary naming: `iso` (nuwiki default — `YYYY-Www` labels, - /// always Monday-based) or `date`/`vimwiki` (the week-start day's date - /// `YYYY-MM-DD`, honouring `diary_start_week_day`). Only affects - /// `diary_frequency = weekly`. - pub diary_weekly_style: String, - /// Weekday the diary week begins on (`monday`..`sunday`, vimwiki's - /// `diary_start_week_day`). Used only when `diary_weekly_style = date`. - pub diary_start_week_day: String, - /// Caption level for the diary index page headings (1 = h1). vimwiki's - /// `diary_caption_level` (`min: -1`). nuwiki uses it as the base level of - /// the year/month index tree; values `< 0` clamp to `0` (upstream's `-1` - /// "read no per-page captions" mode isn't modelled — nuwiki builds the - /// tree from dates, not page headers). - pub diary_caption_level: i8, - /// Newest-first (`desc`) or oldest-first (`asc`) in the diary - /// index. - pub diary_sort: String, - /// H1 text for the diary index page. - pub diary_header: String, - /// vimwiki `diary_months`: month-number → display name for the diary - /// index tree. Twelve entries (January..December) by default; a custom - /// list falls back to the English name for any missing/out-of-range slot. - pub diary_months: Vec, - /// Vimwiki's `g:vimwiki_listsyms`. A progression of checkbox glyphs - /// from "empty" (first) to "done" (last); the lexer recognises these - /// glyphs and the toggle/cycle commands walk them. Any length ≥ 2 is - /// honoured. Default: `" .oOX"`. - pub listsyms: String, - /// Vimwiki's `g:vimwiki_listsym_rejected` — the glyph for a cancelled - /// checkbox (`[-]` by default). First character is used. - pub listsym_rejected: String, - /// When toggling a parent list item, also cascade to descendants. - pub listsyms_propagate: bool, - /// vimwiki `list_margin`: number of leading spaces before bullets in - /// **buffer-side generated lists** — `:VimwikiGenerateLinks`, the TOC, the - /// tags index, and the diary index. `>= 0` indents by that many spaces; - /// `< 0` means "no margin" (default `-1`; `0` for markdown wikis). - /// Divergence: upstream resolves `< 0` to the buffer's `'shiftwidth'`, - /// which the server can't observe, so negatives collapse to zero indent. - pub list_margin: i32, - /// Character used in place of literal spaces inside wikilink - /// targets when writing to disk (a single space `" "` by default, - /// i.e. spaces are kept verbatim). - pub links_space_char: String, - /// Rebuild the page's TOC on save when set. - pub auto_toc: bool, - /// On save, regenerate an existing `Generated Links` section - /// (vimwiki's `auto_generate_links`). - pub auto_generate_links: bool, - /// On save, regenerate an existing `Generated Tags` index section - /// (vimwiki's `auto_generate_tags`). - pub auto_generate_tags: bool, - /// On save of a diary entry, regenerate the diary index page - /// (vimwiki's `auto_diary_index`). - pub auto_diary_index: bool, - /// Heading text + level for the generated `:VimwikiTOC` section - /// (vimwiki's `toc_header` / `toc_header_level`). - pub toc_header: String, - pub toc_header_level: u8, - /// Heading text + level for `:VimwikiGenerateLinks` (`links_header` / - /// `links_header_level`). - pub links_header: String, - pub links_header_level: u8, - /// Heading text + level for the no-arg `:VimwikiGenerateTagLinks` index - /// (`tags_header` / `tags_header_level`). - pub tags_header: String, - pub tags_header_level: u8, - /// vimwiki `create_link` (default `true`): when following a link to a - /// missing page, create it. When `false`, missing-link follow is a no-op. - pub create_link: bool, - /// vimwiki `dir_link`: index file opened when following a link to a - /// directory (e.g. `index` → `dir/index.wiki`). Empty = open the directory. - pub dir_link: String, - /// vimwiki `bullet_types`: ordered list of unordered-bullet glyphs for - /// this wiki's syntax (default `-`, `*`, `#`); drives `cycle_bullets`. - pub bullet_types: Vec, - /// vimwiki `cycle_bullets` (default `false`): rotate an unordered item's - /// glyph through `bullet_types` as it is indented/dedented. - pub cycle_bullets: bool, - /// vimwiki `generated_links_caption` (default `false`): emit - /// `[[page|Heading]]` (first heading as caption) from `:VimwikiGenerateLinks`. - pub generated_links_caption: bool, - /// vimwiki `toc_link_format` (default `0`): `0` = `[[#anchor|title]]` - /// (with description); `1` = `[[#anchor]]` (anchor only, no description). - pub toc_link_format: u8, - /// vimwiki `table_reduce_last_col` (default `false`): don't pad the last - /// table column to fill — keep it at its minimum width. - pub table_reduce_last_col: bool, - /// HTML export options. - pub html: HtmlConfig, -} - -/// Per-wiki HTML export settings. All paths are absolute (relative -/// paths from `initializationOptions` are resolved against the wiki -/// root at config-load time). -#[derive(Debug, Clone)] -pub struct HtmlConfig { - /// Output directory for `nuwiki.export.*` commands. - /// Default: `/_html`. - pub html_path: PathBuf, - /// Template search root. Default: `/_templates`. - pub template_path: PathBuf, - /// Template name used when a page has no `%template` directive. - pub template_default: String, - /// Template file extension appended when looking up a template. - pub template_ext: String, - /// `%Y`/`%m`/`%d` format string passed to `{{date}}`. - pub template_date_format: String, - /// CSS file name relative to `html_path` (copied/created if missing). - pub css_name: String, - /// Run `currentToHtml` automatically on `textDocument/didSave`. - pub auto_export: bool, - /// When true, the output filename for a page is URL-safe-slugified. - pub html_filename_parameterization: bool, - /// Glob patterns (matched against the page name relative to root) - /// to skip during `allToHtml*`. - pub exclude_files: Vec, - /// `color_dic`: vimwiki colour-tag name → CSS value mapping used - /// by the HTML renderer. Empty by default; the - /// renderer falls back to `class="color-"` when a name - /// isn't in the dict. - pub color_dic: std::collections::HashMap, - /// External wiki→HTML converter (vimwiki's `custom_wiki2html`). When - /// non-empty, export shells out to it instead of the built-in renderer. - pub custom_wiki2html: String, - /// Extra args appended to the `custom_wiki2html` invocation. - pub custom_wiki2html_args: String, - /// URL prefix prepended to RSS feed / diary item links (`base_url`). - pub base_url: String, - /// vimwiki `html_header_numbering`: heading level at which automatic - /// section numbering starts in HTML export (`0` = off, the default). - pub html_header_numbering: u8, - /// vimwiki `html_header_numbering_sym`: symbol appended after the - /// section number (e.g. `.` → `1.2. Heading`). Empty by default. - pub html_header_numbering_sym: String, - /// vimwiki `toc_header`: the heading text that identifies the TOC - /// section. When a heading matches this value (case-insensitive), it - /// gets `class="toc"` in HTML export. - pub toc_header: String, - /// vimwiki `rss_name`: filename of the generated RSS feed (default - /// `rss.xml`), relative to `html_path`. - pub rss_name: String, - /// vimwiki `rss_max_items`: cap on the number of diary items in the RSS - /// feed (default `10`; `0` = unlimited-but-empty per upstream's min). - pub rss_max_items: usize, - /// vimwiki `valid_html_tags`: inline HTML tag names that pass through to - /// HTML export unescaped (everything else has `<`/`>` escaped). Default - /// `b,i,s,u,sub,sup,kbd,br,hr,div,center,strong,em`. - pub valid_html_tags: Vec, - /// vimwiki `list_ignore_newline` (default `true`): a single newline inside - /// a list item is a space in HTML export; when `false` it becomes `
              `. - pub list_ignore_newline: bool, - /// vimwiki `text_ignore_newline` (default `true`): a single newline inside - /// a paragraph is a space in HTML export; when `false` it becomes `
              `. - pub text_ignore_newline: bool, - /// vimwiki `emoji_enable` (default `true`): substitute `:alias:` emoji - /// shortcodes with their glyph during HTML export. - pub emoji_enable: bool, - /// vimwiki `user_htmls`: basenames of HTML files with no wiki source that - /// `allToHtml` must not prune. Empty by default. - pub user_htmls: Vec, - /// vimwiki `color_tag_template`: HTML template for a colour span whose name - /// resolves in `color_dic`. `__STYLE__` expands to the inline style - /// (`color:`) and `__CONTENT__` to the rendered inner HTML. Default - /// matches upstream's `` template. Consumed by - /// `HtmlRenderer::render_color`. - pub color_tag_template: String, -} - -impl Default for HtmlConfig { - fn default() -> Self { - Self { - html_path: PathBuf::new(), - template_path: PathBuf::new(), - template_default: "default".into(), - template_ext: ".tpl".into(), - template_date_format: "%Y-%m-%d".into(), - css_name: "style.css".into(), - auto_export: false, - html_filename_parameterization: false, - exclude_files: Vec::new(), - color_dic: default_color_dic(), - custom_wiki2html: String::new(), - custom_wiki2html_args: String::new(), - base_url: String::new(), - html_header_numbering: 0, - html_header_numbering_sym: String::new(), - toc_header: default_toc_header(), - rss_name: "rss.xml".into(), - rss_max_items: 10, - valid_html_tags: default_valid_html_tags(), - list_ignore_newline: true, - text_ignore_newline: true, - emoji_enable: true, - user_htmls: Vec::new(), - color_tag_template: default_color_tag_template(), - } - } -} - -/// vimwiki's default `valid_html_tags` — inline tags allowed through export. -fn default_valid_html_tags() -> Vec { - "b,i,s,u,sub,sup,kbd,br,hr,div,center,strong,em" - .split(',') - .map(|s| s.to_string()) - .collect() -} - -/// vimwiki's default `color_tag_template`. -fn default_color_tag_template() -> String { - "__CONTENT__".to_string() -} - -/// A populated default `color_dic` (vimwiki ships a palette rather than the -/// empty map nuwiki previously defaulted to). Names → CSS colour value. -fn default_color_dic() -> std::collections::HashMap { - [ - ("default", "inherit"), - ("red", "red"), - ("green", "green"), - ("blue", "blue"), - ("yellow", "#b8860b"), - ("magenta", "magenta"), - ("cyan", "darkcyan"), - ("gray", "gray"), - ("grey", "gray"), - ] - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() -} - -impl HtmlConfig { - /// Synthesise default paths under `root` when the user hasn't - /// specified explicit ones. `html_path` defaults to `/_html` - /// and `template_path` to `/_templates`. - pub fn from_root(root: &Path) -> Self { - Self { - html_path: root.join("_html"), - template_path: root.join("_templates"), - ..Self::default() - } - } -} - -#[derive(Debug, Clone, Default)] -pub struct DiagnosticConfig { - pub link_severity: LinkSeverity, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum LinkSeverity { - Off, - Hint, - #[default] - Warning, - Error, -} - -impl LinkSeverity { - /// Parse a client-supplied severity string. Case-insensitive; - /// accepts both `warn` and `warning`. Returns `None` for unknown - /// values so callers can fall back to the default. - pub fn parse(s: &str) -> Option { - match s.trim().to_ascii_lowercase().as_str() { - "off" => Some(Self::Off), - "hint" => Some(Self::Hint), - "warn" | "warning" => Some(Self::Warning), - "error" => Some(Self::Error), - _ => None, - } - } -} - -impl WikiConfig { - /// Synthetic default used when the client supplies neither - /// `initializationOptions` nor a workspace root. `name` is empty; - /// callers shouldn't depend on it for routing. - pub fn empty() -> Self { - let d = wiki_defaults(); - Self { - name: String::new(), - root: PathBuf::new(), - file_extension: ".wiki".into(), - syntax: "vimwiki".into(), - index: d.index, - diary_rel_path: d.diary_rel_path, - diary_index: d.diary_index, - diary_frequency: d.diary_frequency, - diary_weekly_style: d.diary_weekly_style, - diary_start_week_day: d.diary_start_week_day, - diary_caption_level: d.diary_caption_level, - diary_sort: d.diary_sort, - diary_header: d.diary_header, - diary_months: d.diary_months, - listsyms: d.listsyms, - listsym_rejected: d.listsym_rejected, - listsyms_propagate: d.listsyms_propagate, - list_margin: d.list_margin, - links_space_char: d.links_space_char, - auto_toc: d.auto_toc, - auto_generate_links: d.auto_generate_links, - auto_generate_tags: d.auto_generate_tags, - auto_diary_index: d.auto_diary_index, - toc_header: d.toc_header, - toc_header_level: d.toc_header_level, - links_header: d.links_header, - links_header_level: d.links_header_level, - tags_header: d.tags_header, - tags_header_level: d.tags_header_level, - create_link: d.create_link, - dir_link: d.dir_link, - bullet_types: d.bullet_types, - cycle_bullets: d.cycle_bullets, - generated_links_caption: d.generated_links_caption, - toc_link_format: d.toc_link_format, - table_reduce_last_col: d.table_reduce_last_col, - html: HtmlConfig::default(), - } - } - - pub fn from_root(root: PathBuf) -> Self { - let html = HtmlConfig::from_root(&root); - let d = wiki_defaults(); - Self { - name: root - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_default(), - root, - file_extension: ".wiki".into(), - syntax: "vimwiki".into(), - index: d.index, - diary_rel_path: d.diary_rel_path, - diary_index: d.diary_index, - diary_frequency: d.diary_frequency, - diary_weekly_style: d.diary_weekly_style, - diary_start_week_day: d.diary_start_week_day, - diary_caption_level: d.diary_caption_level, - diary_sort: d.diary_sort, - diary_header: d.diary_header, - diary_months: d.diary_months, - listsyms: d.listsyms, - listsym_rejected: d.listsym_rejected, - listsyms_propagate: d.listsyms_propagate, - list_margin: d.list_margin, - links_space_char: d.links_space_char, - auto_toc: d.auto_toc, - auto_generate_links: d.auto_generate_links, - auto_generate_tags: d.auto_generate_tags, - auto_diary_index: d.auto_diary_index, - toc_header: d.toc_header, - toc_header_level: d.toc_header_level, - links_header: d.links_header, - links_header_level: d.links_header_level, - tags_header: d.tags_header, - tags_header_level: d.tags_header_level, - create_link: d.create_link, - dir_link: d.dir_link, - bullet_types: d.bullet_types, - cycle_bullets: d.cycle_bullets, - generated_links_caption: d.generated_links_caption, - toc_link_format: d.toc_link_format, - table_reduce_last_col: d.table_reduce_last_col, - html, - } - } - - /// Absolute path to the diary subdirectory under this wiki's root. - pub fn diary_dir(&self) -> PathBuf { - self.root.join(&self.diary_rel_path) - } - - /// Absolute path to this wiki's diary index page. - pub fn diary_index_path(&self) -> PathBuf { - let mut p = self.diary_dir(); - p.push(format!("{}{}", self.diary_index, self.file_extension)); - p - } - - /// Absolute path for a diary entry on `date`. - pub fn diary_path_for(&self, date: &nuwiki_core::date::DiaryDate) -> PathBuf { - let mut p = self.diary_dir(); - p.push(format!("{}{}", date.format(), self.file_extension)); - p - } - - /// Absolute path for a diary entry covering `period`. Honours the - /// wiki's `diary_frequency` indirectly — callers compute the period - /// at the right cadence and we just pick the stem from its format. - pub fn diary_path_for_period(&self, period: &nuwiki_core::date::DiaryPeriod) -> PathBuf { - let mut p = self.diary_dir(); - p.push(format!("{}{}", period.format(), self.file_extension)); - p - } - - /// `DiaryFrequency` parsed from this wiki's `diary_frequency` field. - pub fn frequency(&self) -> nuwiki_core::date::DiaryFrequency { - nuwiki_core::date::DiaryFrequency::parse(&self.diary_frequency) - } - - /// The checkbox palette for this wiki — the `listsyms` progression plus - /// the configured `listsym_rejected` glyph (default `-`). - pub fn list_syms(&self) -> nuwiki_core::listsyms::ListSyms { - let rejected = self.listsym_rejected.chars().next().unwrap_or('-'); - nuwiki_core::listsyms::ListSyms::new_with_rejected(&self.listsyms, rejected) - } - - /// The diary navigation policy (frequency + weekly naming/week-start), - /// used by the diary commands to compute today / next / prev. - pub fn diary_calendar(&self) -> nuwiki_core::date::DiaryCalendar { - nuwiki_core::date::DiaryCalendar::new( - self.frequency(), - nuwiki_core::date::WeeklyStyle::parse(&self.diary_weekly_style), - nuwiki_core::date::WeekStart::parse(&self.diary_start_week_day), - ) - } -} - -fn default_diary_rel_path() -> String { - "diary".to_string() -} - -fn default_diary_index() -> String { - "diary".to_string() -} - -fn default_index() -> String { - "index".to_string() -} - -fn default_diary_frequency() -> String { - "daily".to_string() -} - -fn default_diary_weekly_style() -> String { - // nuwiki's original ISO-week labels; opt into `date` for vimwiki parity. - "iso".to_string() -} - -fn default_diary_start_week_day() -> String { - "monday".to_string() -} - -fn default_diary_sort() -> String { - "desc".to_string() -} - -fn default_diary_header() -> String { - "Diary".to_string() -} - -/// vimwiki's default `diary_months` — the English month names, January..December. -fn default_diary_months() -> Vec { - [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ] - .iter() - .map(|s| s.to_string()) - .collect() -} - -fn default_listsyms() -> String { - " .oOX".to_string() -} - -fn default_listsym_rejected() -> String { - "-".to_string() -} - -fn default_toc_header() -> String { - "Contents".to_string() -} - -fn default_links_header() -> String { - "Generated Links".to_string() -} - -fn default_tags_header() -> String { - "Generated Tags".to_string() -} - -fn default_links_space_char() -> String { - " ".to_string() -} - -/// Fill in the per-wiki keys that none of the constructors care -/// about. Centralising the defaults here keeps `empty()`, `from_root()`, -/// the legacy single-wiki path, and `From` in sync. -fn wiki_defaults() -> WikiDefaults { - WikiDefaults { - index: default_index(), - diary_rel_path: default_diary_rel_path(), - diary_index: default_diary_index(), - diary_frequency: default_diary_frequency(), - diary_weekly_style: default_diary_weekly_style(), - diary_start_week_day: default_diary_start_week_day(), - // Match upstream vimwiki's `diary_caption_level` default of 0 (year - // captions at the top level, months one below). Users override per-wiki. - diary_caption_level: 0, - diary_sort: default_diary_sort(), - diary_header: default_diary_header(), - diary_months: default_diary_months(), - listsyms: default_listsyms(), - listsym_rejected: default_listsym_rejected(), - listsyms_propagate: true, - list_margin: -1, - links_space_char: default_links_space_char(), - auto_toc: false, - auto_generate_links: false, - auto_generate_tags: false, - auto_diary_index: false, - toc_header: default_toc_header(), - toc_header_level: 1, - links_header: default_links_header(), - links_header_level: 1, - tags_header: default_tags_header(), - tags_header_level: 1, - create_link: true, - dir_link: String::new(), - bullet_types: default_bullet_types(), - cycle_bullets: false, - generated_links_caption: false, - toc_link_format: 0, - table_reduce_last_col: false, - } -} - -/// vimwiki's default `bullet_types` for the `vimwiki` syntax. -fn default_bullet_types() -> Vec { - ["-", "*", "#"].iter().map(|s| s.to_string()).collect() -} - -struct WikiDefaults { - index: String, - diary_rel_path: String, - diary_index: String, - diary_frequency: String, - diary_weekly_style: String, - diary_start_week_day: String, - diary_caption_level: i8, - diary_sort: String, - diary_header: String, - diary_months: Vec, - listsyms: String, - listsym_rejected: String, - listsyms_propagate: bool, - list_margin: i32, - links_space_char: String, - auto_toc: bool, - auto_generate_links: bool, - auto_generate_tags: bool, - auto_diary_index: bool, - toc_header: String, - toc_header_level: u8, - links_header: String, - links_header_level: u8, - tags_header: String, - tags_header_level: u8, - create_link: bool, - dir_link: String, - bullet_types: Vec, - cycle_bullets: bool, - generated_links_caption: bool, - toc_link_format: u8, - table_reduce_last_col: bool, -} - -impl Config { - /// Build a `Config` from `InitializeParams`. - /// - /// Priority for the wikis list: - /// 1. `initialization_options.wikis = [...]` (multi-wiki shape) - /// 2. `initialization_options.wiki_root + file_extension + syntax` - /// (legacy single-wiki shape) - /// 3. `workspace_folders[0]` if present - /// 4. deprecated `root_uri` if present - /// 5. empty list (server stays alive but won't index anything) - pub fn from_init_params(params: &InitializeParams) -> Self { - let raw = params - .initialization_options - .as_ref() - .and_then(|v| serde_json::from_value::(v.clone()).ok()) - .unwrap_or_default(); - - let wikis = if let Some(list) = raw.wikis { - list.into_iter().map(WikiConfig::from).collect() - } else if let Some(wc) = params - .initialization_options - .as_ref() - .and_then(single_wiki_from_value) - { - vec![wc] - } else if let Some(folder) = first_workspace_folder(params) { - vec![WikiConfig::from_root(folder)] - } else { - Vec::new() - }; - - Config { - wikis, - diagnostic: diagnostic_from_raw(&raw.diagnostic), - } - } - - /// Re-apply settings received via `workspace/didChangeConfiguration`. - /// On parse failure the existing config is returned unchanged. - /// - /// Accepts both the flat shape (`{ "wikis": [...] }`) produced by the - /// VimL client, **and** the Neovim-style wrapper (`{ "nuwiki": { ... } }`) - /// that `server_settings()` in `lua/nuwiki/lsp.lua` produces. Any other - /// single-key object whose value is an object is also unwrapped so - /// editors that namespace settings by server name continue to work. - pub fn apply_change(&mut self, value: &serde_json::Value) { - // Unwrap { "nuwiki": { ... } } → { ... } so both clients work. - // Only unwrap when the lone key isn't itself a recognised top-level - // setting — otherwise a minimal payload like - // `{ "diagnostic": { ... } }` would be mistaken for a namespace - // wrapper and its contents silently dropped. - let inner = value - .as_object() - .and_then(|m| if m.len() == 1 { m.iter().next() } else { None }) - .filter(|(k, v)| v.is_object() && !is_init_option_key(k)) - .map(|(_, v)| v) - .unwrap_or(value); - - let raw: InitOptions = match serde_json::from_value(inner.clone()) { - Ok(r) => r, - Err(_) => return, - }; - if let Some(list) = raw.wikis { - self.wikis = list.into_iter().map(WikiConfig::from).collect(); - } else if let Some(wc) = single_wiki_from_value(inner) { - self.wikis = vec![wc]; - } - // Only touch diagnostic settings when the payload carries them, so a - // partial `didChangeConfiguration` doesn't reset severity to default. - if raw.diagnostic.is_some() { - self.diagnostic = diagnostic_from_raw(&raw.diagnostic); - } - } -} - -// ===== Wire schema ===== - -/// Custom serde helper: accept JSON `true`/`false` **or** integers `0`/`1` -/// for `Option` fields. VimL dicts serialise `auto_export = 1` as -/// the JSON number `1`, not `true`, which would otherwise silently break -/// deserialisation of the entire `RawWiki` and cause the server to fall -/// back to `wiki_root` (ignoring the wikis list and marking every link -/// broken). -mod opt_bool_or_int { - use serde::{Deserialize, Deserializer}; - - pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - let v = Option::::deserialize(deserializer)?; - match v { - None | Some(serde_json::Value::Null) => Ok(None), - Some(serde_json::Value::Bool(b)) => Ok(Some(b)), - Some(serde_json::Value::Number(ref n)) => { - if let Some(i) = n.as_i64() { - Ok(Some(i != 0)) - } else { - Err(serde::de::Error::custom( - "expected bool or 0/1 integer for bool field", - )) - } - } - Some(other) => Err(serde::de::Error::custom(format!( - "expected bool or 0/1 integer, got {other}" - ))), - } - } -} - -/// Recognised top-level keys in the flat `initializationOptions` shape. -/// Used to tell a real single-key payload (e.g. `{ "diagnostic": {…} }`) -/// apart from an editor namespace wrapper (e.g. `{ "nuwiki": {…} }`) in -/// [`Config::apply_change`]. -fn is_init_option_key(key: &str) -> bool { - matches!( - key, - "wikis" | "wiki_root" | "file_extension" | "syntax" | "log_level" | "diagnostic" - ) -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, rename_all = "snake_case")] -struct InitOptions { - wikis: Option>, - // legacy single-wiki compat fields - wiki_root: Option, - file_extension: Option, - syntax: Option, - log_level: Option, - diagnostic: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -struct RawDiagnostic { - #[serde(default)] - link_severity: Option, -} - -/// Build a [`DiagnosticConfig`] from the raw client payload, falling back -/// to defaults for absent or unrecognised values. -fn diagnostic_from_raw(raw: &Option) -> DiagnosticConfig { - let link_severity = raw - .as_ref() - .and_then(|d| d.link_severity.as_deref()) - .and_then(LinkSeverity::parse) - .unwrap_or_default(); - DiagnosticConfig { link_severity } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -struct RawWiki { - #[serde(default)] - name: Option, - root: String, - #[serde(default)] - file_extension: Option, - #[serde(default)] - syntax: Option, - #[serde(default)] - diary_rel_path: Option, - #[serde(default)] - diary_index: Option, - // HTML keys — all optional, fall back to per-root defaults. - #[serde(default)] - html_path: Option, - #[serde(default)] - template_path: Option, - #[serde(default)] - template_default: Option, - #[serde(default)] - template_ext: Option, - #[serde(default)] - template_date_format: Option, - #[serde(default)] - css_name: Option, - #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] - auto_export: Option, - #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] - html_filename_parameterization: Option, - #[serde(default)] - exclude_files: Option>, - #[serde(default)] - color_dic: Option>, - #[serde(default)] - custom_wiki2html: Option, - #[serde(default)] - custom_wiki2html_args: Option, - #[serde(default)] - base_url: Option, - #[serde(default)] - html_header_numbering: Option, - #[serde(default)] - html_header_numbering_sym: Option, - #[serde(default)] - rss_name: Option, - #[serde(default)] - rss_max_items: Option, - #[serde(default)] - valid_html_tags: Option, - #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] - list_ignore_newline: Option, - #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] - text_ignore_newline: Option, - #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] - emoji_enable: Option, - #[serde(default)] - user_htmls: Option>, - #[serde(default)] - color_tag_template: Option, - // per-wiki keys mirroring vimwiki globals. All optional so - // existing single-wiki configs migrate without touching anything. - #[serde(default)] - index: Option, - #[serde(default)] - diary_frequency: Option, - #[serde(default)] - diary_weekly_style: Option, - #[serde(default)] - diary_start_week_day: Option, - #[serde(default)] - diary_caption_level: Option, - #[serde(default)] - diary_sort: Option, - #[serde(default)] - diary_header: Option, - #[serde(default)] - diary_months: Option>, - #[serde(default)] - listsyms: Option, - #[serde(default)] - listsym_rejected: Option, - #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] - listsyms_propagate: Option, - #[serde(default)] - list_margin: Option, - #[serde(default)] - links_space_char: Option, - #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] - auto_toc: Option, - #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] - auto_generate_links: Option, - #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] - auto_generate_tags: Option, - #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] - auto_diary_index: Option, - #[serde(default)] - toc_header: Option, - #[serde(default)] - toc_header_level: Option, - #[serde(default)] - links_header: Option, - #[serde(default)] - links_header_level: Option, - #[serde(default)] - tags_header: Option, - #[serde(default)] - tags_header_level: Option, - #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] - create_link: Option, - #[serde(default)] - dir_link: Option, - #[serde(default)] - bullet_types: Option>, - #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] - cycle_bullets: Option, - #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] - generated_links_caption: Option, - #[serde(default)] - toc_link_format: Option, - #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] - table_reduce_last_col: Option, -} - -impl From for WikiConfig { - fn from(r: RawWiki) -> Self { - let root = expand_path(&r.root).unwrap_or_default(); - let derived_name = root - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_default(); - let mut html = HtmlConfig::from_root(&root); - if let Some(p) = r.html_path.as_deref().and_then(expand_path) { - html.html_path = p; - } - if let Some(p) = r.template_path.as_deref().and_then(expand_path) { - html.template_path = p; - } - if let Some(s) = r.template_default { - html.template_default = s; - } - if let Some(s) = r.template_ext { - html.template_ext = s; - } - if let Some(s) = r.template_date_format { - html.template_date_format = s; - } - if let Some(s) = r.css_name { - html.css_name = s; - } - if let Some(b) = r.auto_export { - html.auto_export = b; - } - if let Some(b) = r.html_filename_parameterization { - html.html_filename_parameterization = b; - } - if let Some(v) = r.exclude_files { - html.exclude_files = v; - } - if let Some(v) = r.color_dic { - html.color_dic = v; - } - if let Some(s) = r.custom_wiki2html { - html.custom_wiki2html = s; - } - if let Some(s) = r.custom_wiki2html_args { - html.custom_wiki2html_args = s; - } - if let Some(s) = r.base_url { - html.base_url = s; - } - if let Some(n) = r.html_header_numbering { - html.html_header_numbering = n; - } - if let Some(s) = r.html_header_numbering_sym { - html.html_header_numbering_sym = s; - } - if let Some(ref s) = r.toc_header { - html.toc_header = s.clone(); - } - if let Some(s) = r.rss_name { - html.rss_name = s; - } - if let Some(n) = r.rss_max_items { - html.rss_max_items = n; - } - if let Some(s) = r.valid_html_tags { - html.valid_html_tags = s.split(',').map(|t| t.trim().to_string()).collect(); - } - if let Some(b) = r.list_ignore_newline { - html.list_ignore_newline = b; - } - if let Some(b) = r.text_ignore_newline { - html.text_ignore_newline = b; - } - if let Some(b) = r.emoji_enable { - html.emoji_enable = b; - } - if let Some(v) = r.user_htmls { - html.user_htmls = v; - } - if let Some(s) = r.color_tag_template { - html.color_tag_template = s; - } - let d = wiki_defaults(); - let syntax = r.syntax.unwrap_or_else(|| "vimwiki".into()); - // vimwiki derives `list_margin = 0` for markdown wikis when the key is - // unset (vars.vim:651); other syntaxes keep the `-1` default. - let list_margin = r.list_margin.unwrap_or(if syntax == "markdown" { - 0 - } else { - d.list_margin - }); - WikiConfig { - name: r.name.unwrap_or(derived_name), - root, - file_extension: r.file_extension.unwrap_or_else(|| ".wiki".into()), - syntax, - index: r.index.unwrap_or(d.index), - diary_rel_path: r.diary_rel_path.unwrap_or(d.diary_rel_path), - diary_index: r.diary_index.unwrap_or(d.diary_index), - diary_frequency: r.diary_frequency.unwrap_or(d.diary_frequency), - diary_weekly_style: r.diary_weekly_style.unwrap_or(d.diary_weekly_style), - diary_start_week_day: r.diary_start_week_day.unwrap_or(d.diary_start_week_day), - diary_caption_level: r.diary_caption_level.unwrap_or(d.diary_caption_level), - diary_sort: r.diary_sort.unwrap_or(d.diary_sort), - diary_header: r.diary_header.unwrap_or(d.diary_header), - diary_months: r.diary_months.unwrap_or(d.diary_months), - listsyms: r.listsyms.unwrap_or(d.listsyms), - listsym_rejected: r.listsym_rejected.unwrap_or(d.listsym_rejected), - listsyms_propagate: r.listsyms_propagate.unwrap_or(d.listsyms_propagate), - list_margin, - links_space_char: r.links_space_char.unwrap_or(d.links_space_char), - auto_toc: r.auto_toc.unwrap_or(d.auto_toc), - auto_generate_links: r.auto_generate_links.unwrap_or(d.auto_generate_links), - auto_generate_tags: r.auto_generate_tags.unwrap_or(d.auto_generate_tags), - auto_diary_index: r.auto_diary_index.unwrap_or(d.auto_diary_index), - toc_header: r.toc_header.unwrap_or(d.toc_header), - toc_header_level: r.toc_header_level.unwrap_or(d.toc_header_level), - links_header: r.links_header.unwrap_or(d.links_header), - links_header_level: r.links_header_level.unwrap_or(d.links_header_level), - tags_header: r.tags_header.unwrap_or(d.tags_header), - tags_header_level: r.tags_header_level.unwrap_or(d.tags_header_level), - create_link: r.create_link.unwrap_or(d.create_link), - dir_link: r.dir_link.unwrap_or(d.dir_link), - bullet_types: r - .bullet_types - .filter(|v| !v.is_empty()) - .unwrap_or(d.bullet_types), - cycle_bullets: r.cycle_bullets.unwrap_or(d.cycle_bullets), - generated_links_caption: r - .generated_links_caption - .unwrap_or(d.generated_links_caption), - toc_link_format: r.toc_link_format.unwrap_or(d.toc_link_format), - table_reduce_last_col: r.table_reduce_last_col.unwrap_or(d.table_reduce_last_col), - html, - } - } -} - -/// Build the single-wiki [`WikiConfig`] from the flat top-level options object -/// (single-wiki shorthand: a `wiki_root` plus any per-wiki keys set alongside -/// it). Returns `None` when there's no expandable `wiki_root` (so callers fall -/// through to the workspace-folder default) or a `wikis` list is present. -/// -/// Re-uses the full [`RawWiki`] schema so **every** per-wiki key set at the top -/// level — `toc_header`/`toc_header_level`, `links_header`, `html_path`, -/// `auto_export`, … — is honoured, not just `file_extension`/`syntax`. The -/// shorthand keys on `wiki_root`; `RawWiki` keys on `root`, so we inject it. -fn single_wiki_from_value(value: &serde_json::Value) -> Option { - let obj = value.as_object()?; - if obj.contains_key("wikis") { - return None; - } - let wiki_root = obj.get("wiki_root").and_then(|v| v.as_str())?; - // Bail (→ workspace-folder fallback) when the root can't be expanded. - expand_path(wiki_root)?; - let mut wiki_obj = obj.clone(); - wiki_obj.insert( - "root".to_string(), - serde_json::Value::String(wiki_root.to_string()), - ); - let raw: RawWiki = serde_json::from_value(serde_json::Value::Object(wiki_obj)).ok()?; - Some(WikiConfig::from(raw)) -} - -fn expand_path(s: &str) -> Option { - let trimmed = s.trim(); - if trimmed.is_empty() { - return None; - } - // Best-effort `~` expansion. Avoids pulling in `dirs` or `home`. - if let Some(rest) = trimmed.strip_prefix("~/") { - if let Some(home) = std::env::var_os("HOME") { - return Some(Path::new(&home).join(rest)); - } - } else if trimmed == "~" { - if let Some(home) = std::env::var_os("HOME") { - return Some(PathBuf::from(home)); - } - } - Some(PathBuf::from(trimmed)) -} - -fn first_workspace_folder(params: &InitializeParams) -> Option { - if let Some(folders) = ¶ms.workspace_folders { - if let Some(first) = folders.first() { - return first.uri.to_file_path().ok(); - } - } - #[allow(deprecated)] - if let Some(uri) = ¶ms.root_uri { - return uri.to_file_path().ok(); - } - None -} - -/// Test helper: build a `Config` from a JSON value (mimics what arrives -/// in `initializationOptions`). -pub fn config_from_json(value: serde_json::Value) -> Config { - let mut cfg = Config::default(); - cfg.apply_change(&value); - cfg -} diff --git a/crates/nuwiki-lsp/src/diagnostics.rs b/crates/nuwiki-lsp/src/diagnostics.rs deleted file mode 100644 index 8ab613a..0000000 --- a/crates/nuwiki-lsp/src/diagnostics.rs +++ /dev/null @@ -1,335 +0,0 @@ -//! Diagnostic sources beyond parse-time `ErrorNode`s. -//! -//! The `nuwiki.link` source emits broken-link warnings. Walks -//! every `WikiLinkNode` in the AST and emits one diagnostic per: -//! - `Wiki` target that doesn't resolve to a page in the workspace index, -//! - `Wiki`/`AnchorOnly` target with an anchor that matches neither a -//! heading nor a `:tag:` on the target page, -//! - `File`/`Local` target whose resolved path doesn't exist on disk. -//! -//! Interwiki, Diary, Raw, and external links are not diagnosed — -//! interwiki resolution and diary handling live elsewhere, and we -//! don't fetch URLs. - -use std::path::PathBuf; - -use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Url}; - -use nuwiki_core::ast::{ - BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, ListItemNode, ListNode, - TableNode, WikiLinkNode, -}; - -use crate::config::LinkSeverity; -use crate::index::{OutgoingLink, WorkspaceIndex}; -use crate::to_lsp_range; - -pub const LINK_SOURCE: &str = "nuwiki.link"; - -/// Convert a [`LinkSeverity`] to its LSP counterpart. `Off` returns `None` -/// so callers can short-circuit before walking the document. -pub fn severity_to_lsp(s: LinkSeverity) -> Option { - match s { - LinkSeverity::Off => None, - LinkSeverity::Hint => Some(DiagnosticSeverity::HINT), - LinkSeverity::Warning => Some(DiagnosticSeverity::WARNING), - LinkSeverity::Error => Some(DiagnosticSeverity::ERROR), - } -} - -/// Walk the AST and collect every `WikiLinkNode` in source order. -pub fn collect_wiki_links(ast: &DocumentNode) -> Vec<&WikiLinkNode> { - let mut out = Vec::new(); - for block in &ast.children { - walk_block(block, &mut out); - } - out -} - -fn walk_block<'a>(block: &'a BlockNode, out: &mut Vec<&'a WikiLinkNode>) { - match block { - BlockNode::Heading(h) => walk_inlines(&h.children, out), - BlockNode::Paragraph(p) => walk_inlines(&p.children, out), - BlockNode::Blockquote(BlockquoteNode { children, .. }) => { - for c in children { - walk_block(c, out); - } - } - BlockNode::List(ListNode { items, .. }) => { - for item in items { - walk_list_item(item, out); - } - } - BlockNode::DefinitionList(dl) => { - for item in &dl.items { - if let Some(t) = &item.term { - walk_inlines(t, out); - } - for d in &item.definitions { - walk_inlines(d, out); - } - } - } - BlockNode::Table(TableNode { rows, .. }) => { - for row in rows { - for cell in &row.cells { - walk_inlines(&cell.children, out); - } - } - } - _ => {} - } -} - -fn walk_list_item<'a>(item: &'a ListItemNode, out: &mut Vec<&'a WikiLinkNode>) { - walk_inlines(&item.children, out); - if let Some(sub) = &item.sublist { - for sub_it in &sub.items { - walk_list_item(sub_it, out); - } - } -} - -fn walk_inlines<'a>(inlines: &'a [InlineNode], out: &mut Vec<&'a WikiLinkNode>) { - for n in inlines { - match n { - InlineNode::WikiLink(w) => { - out.push(w); - if let Some(d) = &w.description { - walk_inlines(d, out); - } - } - InlineNode::Bold(b) => walk_inlines(&b.children, out), - InlineNode::Italic(i) => walk_inlines(&i.children, out), - InlineNode::BoldItalic(bi) => walk_inlines(&bi.children, out), - InlineNode::Strikethrough(s) => walk_inlines(&s.children, out), - InlineNode::Superscript(s) => walk_inlines(&s.children, out), - InlineNode::Subscript(s) => walk_inlines(&s.children, out), - InlineNode::Color(c) => walk_inlines(&c.children, out), - InlineNode::ExternalLink(e) => { - if let Some(d) = &e.description { - walk_inlines(d, out); - } - } - _ => {} - } - } -} - -/// Classification of why a single wikilink is broken. Returned by -/// [`classify_link`] so callers (diagnostics + `nuwiki.workspace.checkLinks`) -/// share the same logic. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum BrokenLinkKind { - /// Target page doesn't exist in the workspace index. - MissingPage(String), - /// Page exists but the `#anchor` doesn't match any heading or `:tag:`. - MissingAnchor { page: String, anchor: String }, - /// `file:` / `local:` target whose resolved path isn't on disk. - MissingFile(PathBuf), -} - -impl BrokenLinkKind { - pub fn message(&self) -> String { - match self { - BrokenLinkKind::MissingPage(p) => format!("broken wikilink: page '{p}' not in wiki"), - BrokenLinkKind::MissingAnchor { page, anchor } => { - format!("broken anchor: '#{anchor}' not found on page '{page}'") - } - BrokenLinkKind::MissingFile(p) => { - format!("broken file link: '{}' not found", p.display()) - } - } - } - - /// Short machine-readable kind, used in the `checkLinks` JSON output so - /// editors can colour-code or filter. - pub fn tag(&self) -> &'static str { - match self { - BrokenLinkKind::MissingPage(_) => "missing-page", - BrokenLinkKind::MissingAnchor { .. } => "missing-anchor", - BrokenLinkKind::MissingFile(_) => "missing-file", - } - } -} - -/// Inspect one wikilink and return `Some(_)` when it's broken. The `uri` -/// is the source document's URI — required for `file:` / `local:` -/// resolution. `source_page` is the source's name in the index (used by -/// `AnchorOnly` links to look up the current page's headings/tags). -pub fn classify_link( - link: &WikiLinkNode, - uri: Option<&Url>, - index: &WorkspaceIndex, - source_page: &str, -) -> Option { - match link.target.kind { - LinkKind::Wiki => { - let path = link.target.path.as_deref()?; - match index.page_for_wiki_target(path, source_page, link.target.is_absolute) { - None => Some(BrokenLinkKind::MissingPage(path.to_string())), - Some(page) => { - if let Some(anchor) = &link.target.anchor { - if anchor_resolves_on(page, anchor) { - None - } else { - Some(BrokenLinkKind::MissingAnchor { - page: path.to_string(), - anchor: anchor.clone(), - }) - } - } else { - None - } - } - } - } - LinkKind::AnchorOnly => { - let anchor = link.target.anchor.as_deref()?; - let page = index.page_by_name(source_page)?; - if anchor_resolves_on(page, anchor) { - None - } else { - Some(BrokenLinkKind::MissingAnchor { - page: source_page.to_string(), - anchor: anchor.to_string(), - }) - } - } - LinkKind::File | LinkKind::Local => { - let path = link.target.path.as_deref()?; - let resolved = resolve_file_path(uri, index, path, link.target.is_absolute)?; - if resolved.exists() { - None - } else { - Some(BrokenLinkKind::MissingFile(resolved)) - } - } - LinkKind::Interwiki | LinkKind::Diary | LinkKind::Raw => None, - } -} - -fn anchor_resolves_on(page: &crate::index::IndexedPage, anchor: &str) -> bool { - page.find_heading_by_anchor(anchor).is_some() || page.tags.iter().any(|t| t.name == anchor) -} - -/// Same classification as [`classify_link`] but driven from the cached -/// [`OutgoingLink`] data so workspace-wide checks don't have to re-parse -/// every page. The on-disk `File`/`Local` check is performed when the -/// source URI is available. -pub fn classify_outgoing( - link: &OutgoingLink, - source_uri: &Url, - index: &WorkspaceIndex, - source_page: &str, -) -> Option { - match link.kind { - LinkKind::Wiki => { - let path = link.target_page.as_deref()?; - match index.page_for_wiki_target(path, source_page, link.is_absolute) { - None => Some(BrokenLinkKind::MissingPage(path.to_string())), - Some(page) => { - if let Some(anchor) = &link.anchor { - if anchor_resolves_on(page, anchor) { - None - } else { - Some(BrokenLinkKind::MissingAnchor { - page: path.to_string(), - anchor: anchor.clone(), - }) - } - } else { - None - } - } - } - } - LinkKind::AnchorOnly => { - let anchor = link.anchor.as_deref()?; - let page = index.page_by_name(source_page)?; - if anchor_resolves_on(page, anchor) { - None - } else { - Some(BrokenLinkKind::MissingAnchor { - page: source_page.to_string(), - anchor: anchor.to_string(), - }) - } - } - LinkKind::File | LinkKind::Local => { - let path = link.target_page.as_deref()?; - let resolved = resolve_file_path(Some(source_uri), index, path, link.is_absolute)?; - if resolved.exists() { - None - } else { - Some(BrokenLinkKind::MissingFile(resolved)) - } - } - LinkKind::Interwiki | LinkKind::Diary | LinkKind::Raw => None, - } -} - -/// Flatten heading inlines down to a plain string. Used by `commands::ops` -/// to match heading text against the configured TOC/Links section name, and -/// (via `nuwiki_core::ast::inline_text`) the same text the HTML renderer uses -/// for heading `id`s — so anchors validated here match anchors emitted there. -pub fn heading_text(inlines: &[InlineNode]) -> String { - nuwiki_core::ast::inline_text(inlines) -} - -/// Resolve a `file:` / `local:` link target to an absolute path. -/// -/// - Absolute (parsed `//path` or `is_absolute`): used as-is. -/// - Otherwise: joined with the source file's parent directory. -/// - If we don't have a URI to anchor relative paths against, falls back -/// to the wiki root from the index. -fn resolve_file_path( - uri: Option<&Url>, - index: &WorkspaceIndex, - path: &str, - is_absolute: bool, -) -> Option { - let candidate = PathBuf::from(path); - if is_absolute || candidate.is_absolute() { - return Some(candidate); - } - if let Some(uri) = uri { - if let Ok(base) = uri.to_file_path() { - if let Some(parent) = base.parent() { - return Some(parent.join(&candidate)); - } - } - } - index.root.as_ref().map(|r| r.join(&candidate)) -} - -/// `nuwiki.link` diagnostics for a single document. -pub fn link_health( - ast: &DocumentNode, - text: &str, - uri: Option<&Url>, - index: &WorkspaceIndex, - source_page: &str, - severity: LinkSeverity, - utf8: bool, -) -> Vec { - let Some(sev) = severity_to_lsp(severity) else { - return Vec::new(); - }; - let mut out = Vec::new(); - for link in collect_wiki_links(ast) { - if let Some(kind) = classify_link(link, uri, index, source_page) { - out.push(Diagnostic { - range: to_lsp_range(&link.span, text, utf8), - severity: Some(sev), - source: Some(LINK_SOURCE.into()), - message: kind.message(), - code: Some(tower_lsp::lsp_types::NumberOrString::String( - kind.tag().to_string(), - )), - ..Diagnostic::default() - }); - } - } - out -} diff --git a/crates/nuwiki-lsp/src/diary.rs b/crates/nuwiki-lsp/src/diary.rs deleted file mode 100644 index 28aa04c..0000000 --- a/crates/nuwiki-lsp/src/diary.rs +++ /dev/null @@ -1,329 +0,0 @@ -//! Diary subsystem — pure helpers used by the -//! `nuwiki.diary.*` `executeCommand` handlers. -//! -//! All path/URI math lives here so the command dispatcher stays a thin -//! wrapper. The diary navigation model is intentionally lightweight: -//! entries are identified by file stem (`YYYY-MM-DD`) living under -//! `//`. The `WorkspaceIndex` tags each -//! `IndexedPage` with a `diary_date` so prev/next/list operations stay -//! O(n) over the indexed pages. - -use std::path::Path; - -use tower_lsp::lsp_types::Url; - -use nuwiki_core::date::{DiaryDate, DiaryPeriod}; - -use crate::config::WikiConfig; -use crate::index::WorkspaceIndex; - -/// Resolve a `DiaryDate` to its `file://` URI under the given wiki. -pub fn uri_for_date(cfg: &WikiConfig, date: &DiaryDate) -> Option { - Url::from_file_path(cfg.diary_path_for(date)).ok() -} - -/// Resolve a `DiaryPeriod` to its `file://` URI under the given wiki. -/// Picks the right stem for the period's flavour (day / week / month / year). -pub fn uri_for_period(cfg: &WikiConfig, period: &DiaryPeriod) -> Option { - Url::from_file_path(cfg.diary_path_for_period(period)).ok() -} - -/// `file://` URI of the diary index page. -pub fn index_uri(cfg: &WikiConfig) -> Option { - Url::from_file_path(cfg.diary_index_path()).ok() -} - -/// All diary entries currently indexed for `wiki`, sorted ascending by -/// date. The wiki's index must already have its `diary_rel_path` set -/// (handled by `Wiki::new`). -pub fn list_entries(index: &WorkspaceIndex) -> Vec { - let mut out: Vec = index - .pages_by_uri - .values() - .filter_map(|p| { - p.diary_date.map(|d| DiaryEntry { - date: d, - uri: p.uri.clone(), - }) - }) - .collect(); - out.sort_by(|a, b| a.date.cmp(&b.date)); - out -} - -/// Entries for a given (year, month). `month = None` returns the whole -/// year; `year = None` matches every year. -pub fn list_entries_filtered( - index: &WorkspaceIndex, - year: Option, - month: Option, -) -> Vec { - list_entries(index) - .into_iter() - .filter(|e| year.is_none_or(|y| e.date.year == y)) - .filter(|e| month.is_none_or(|m| e.date.month == m)) - .collect() -} - -/// The diary entry chronologically after `from`. `from` doesn't need to -/// be indexed itself — we look for the smallest indexed date strictly -/// greater than `from`. -pub fn next_entry(index: &WorkspaceIndex, from: &DiaryDate) -> Option { - list_entries(index).into_iter().find(|e| e.date > *from) -} - -/// The diary entry chronologically before `from`. -pub fn prev_entry(index: &WorkspaceIndex, from: &DiaryDate) -> Option { - list_entries(index) - .into_iter() - .rev() - .find(|e| e.date < *from) -} - -/// All indexed diary periods of a *specific* flavour (daily / weekly / -/// monthly / yearly), sorted ascending. Used by next/prev navigation so -/// `` on a weekly page jumps to the next weekly entry, not the -/// next daily one. -pub fn list_periods_of_kind(index: &WorkspaceIndex, kind: PeriodKind) -> Vec { - let mut out: Vec = index - .pages_by_uri - .values() - .filter_map(|p| { - let period = p.diary_period?; - if PeriodKind::of(&period) != kind { - return None; - } - Some(PeriodEntry { - period, - uri: p.uri.clone(), - }) - }) - .collect(); - out.sort_by_key(|e| period_first_day(&e.period)); - out -} - -pub fn next_period(index: &WorkspaceIndex, from: &DiaryPeriod) -> Option { - let kind = PeriodKind::of(from); - let pivot = period_first_day(from); - list_periods_of_kind(index, kind) - .into_iter() - .find(|e| period_first_day(&e.period) > pivot) -} - -pub fn prev_period(index: &WorkspaceIndex, from: &DiaryPeriod) -> Option { - let kind = PeriodKind::of(from); - let pivot = period_first_day(from); - list_periods_of_kind(index, kind) - .into_iter() - .rev() - .find(|e| period_first_day(&e.period) < pivot) -} - -/// Cluster 4: `(period, uri)` pair for non-daily diary navigation. -#[derive(Debug, Clone)] -pub struct PeriodEntry { - pub period: DiaryPeriod, - pub uri: Url, -} - -impl serde::Serialize for PeriodEntry { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - use serde::ser::SerializeStruct; - // For back-compat with daily-only clients, emit `date` AND - // `period` — the former is just the first-day string of the - // period, the latter is the canonical stem. - let mut s = serializer.serialize_struct("PeriodEntry", 3)?; - s.serialize_field("date", &self.period.first_day().format())?; - s.serialize_field("period", &self.period.format())?; - s.serialize_field("uri", &self.uri)?; - s.end() - } -} - -/// Which flavour of `DiaryPeriod` we're looking at. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PeriodKind { - Day, - Week, - Month, - Year, -} - -impl PeriodKind { - pub fn of(p: &DiaryPeriod) -> Self { - match p { - DiaryPeriod::Day(_) => Self::Day, - DiaryPeriod::Week { .. } => Self::Week, - DiaryPeriod::Month { .. } => Self::Month, - DiaryPeriod::Year { .. } => Self::Year, - } - } -} - -fn period_first_day(p: &DiaryPeriod) -> DiaryDate { - p.first_day() -} - -/// Ordering of entries in the diary index page. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DiarySort { - /// Newest first (vimwiki default). - Desc, - /// Oldest first. - Asc, -} - -impl DiarySort { - /// Parse a wiki's `diary_sort` field. Anything other than `asc` - /// (case-insensitive) is treated as the default `desc`. - pub fn parse(s: &str) -> Self { - if s.trim().eq_ignore_ascii_case("asc") { - Self::Asc - } else { - Self::Desc - } - } -} - -/// Render a heading line at `level` (1 = `= … =`). Clamped to vimwiki's -/// 1..=6 range so a stray config value can't emit a 200-equals heading. -fn heading(level: u8, text: &str) -> String { - let n = level.clamp(1, 6) as usize; - let eq = "=".repeat(n); - format!("{eq} {text} {eq}") -} - -/// Generate the body of the diary index page — a flat list of -/// `[[diary/YYYY-MM-DD]]` wikilinks, grouped under year and month -/// subheadings so the rendered page mirrors `:VimwikiDiaryGenerateLinks`. -/// -/// `caption_level` sets the level of the top caption; the year and month -/// subheadings nest one and two levels below it. A `caption_level < 0` -/// clamps to `0` (nuwiki builds the tree from dates, so upstream's `-1` -/// "no per-page captions" mode doesn't apply). `months` supplies the -/// month-number → display name table; an empty slice (or a short list) -/// falls back to the English `month_name` for any missing slot. `sort` -/// controls whether the flat list runs newest- or oldest-first. `margin` -/// is the number of leading spaces before each entry bullet (vimwiki's -/// `list_margin`). -pub fn build_index_body( - entries: &[DiaryEntry], - diary_rel_path: &str, - index_heading: &str, - sort: DiarySort, - caption_level: i8, - months: &[String], - margin: usize, -) -> String { - let base_level = caption_level.max(0) as u8; - let pad = " ".repeat(margin); - let mut out = String::new(); - out.push_str(&heading(base_level, index_heading)); - out.push('\n'); - - if entries.is_empty() { - return out; - } - - let mut sorted: Vec<&DiaryEntry> = entries.iter().collect(); - sorted.sort_by(|a, b| match sort { - DiarySort::Desc => b.date.cmp(&a.date), - DiarySort::Asc => a.date.cmp(&b.date), - }); - - let year_level = base_level.saturating_add(1); - let month_level = base_level.saturating_add(2); - let mut current_year: Option = None; - let mut current_month: Option = None; - for e in sorted { - if current_year != Some(e.date.year) { - out.push('\n'); - out.push_str(&heading(year_level, &e.date.year.to_string())); - out.push('\n'); - current_year = Some(e.date.year); - current_month = None; - } - if current_month != Some(e.date.month) { - let label = months - .get((e.date.month.saturating_sub(1)) as usize) - .map(String::as_str) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| month_name(e.date.month)); - out.push_str(&heading(month_level, label)); - out.push('\n'); - current_month = Some(e.date.month); - } - out.push_str(&format!( - "{pad}- [[{}/{}]]\n", - diary_rel_path.trim_end_matches('/'), - e.date.format() - )); - } - out -} - -/// Render the diary-index body for the given wiki's currently-indexed -/// entries. Convenience wrapper around [`build_index_body`] that honours -/// the wiki's `diary_header`, `diary_sort`, `diary_caption_level`, and -/// `diary_months`. -pub fn render_index_body(cfg: &WikiConfig, index: &WorkspaceIndex) -> String { - build_index_body( - &list_entries(index), - &cfg.diary_rel_path, - &cfg.diary_header, - DiarySort::parse(&cfg.diary_sort), - cfg.diary_caption_level, - &cfg.diary_months, - cfg.list_margin.max(0) as usize, - ) -} - -/// Cheap "is this path under the diary subdir of this wiki" check — -/// surface-level, no parse needed. -pub fn is_in_diary(cfg: &WikiConfig, path: &Path) -> bool { - path.starts_with(cfg.diary_dir()) -} - -/// One diary entry, returned by listing/navigation queries. The custom -/// `Serialize` emits `{ "date": "YYYY-MM-DD", "uri": "file://..." }` so -/// the client doesn't have to know about [`DiaryDate`]'s internal layout. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DiaryEntry { - pub date: DiaryDate, - pub uri: Url, -} - -impl serde::Serialize for DiaryEntry { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - use serde::ser::SerializeStruct; - let mut s = serializer.serialize_struct("DiaryEntry", 2)?; - s.serialize_field("date", &self.date.format())?; - s.serialize_field("uri", &self.uri)?; - s.end() - } -} - -fn month_name(m: u8) -> &'static str { - match m { - 1 => "January", - 2 => "February", - 3 => "March", - 4 => "April", - 5 => "May", - 6 => "June", - 7 => "July", - 8 => "August", - 9 => "September", - 10 => "October", - 11 => "November", - 12 => "December", - _ => "Unknown", - } -} diff --git a/crates/nuwiki-lsp/src/edits.rs b/crates/nuwiki-lsp/src/edits.rs deleted file mode 100644 index 98fdac8..0000000 --- a/crates/nuwiki-lsp/src/edits.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Helpers for building `WorkspaceEdit`s. -//! -//! Rename, list/table edits, and link/TOC generation all return -//! `WorkspaceEdit`. The builder centralises the byte-offset → LSP-position -//! conversion (UTF-8 or UTF-16, matching whatever was negotiated in -//! `initialize`) so each call site doesn't have to redo it. -//! -//! The builder emits `documentChanges` (with `Operation` for file ops) -//! whenever any file op is queued, and falls back to the simpler -//! `changes` field for pure text-edit batches — the latter is what older -//! LSP 3.15- clients understand. - -use std::collections::HashMap; - -use tower_lsp::lsp_types::{ - CreateFile, DeleteFile, DocumentChangeOperation, DocumentChanges, OneOf, - OptionalVersionedTextDocumentIdentifier, Position, Range, RenameFile, ResourceOp, - TextDocumentEdit, TextEdit, Url, WorkspaceEdit, -}; - -use nuwiki_core::ast::Span; - -use crate::semantic_tokens::LineIndex; - -/// Replace the source covered by `span` with `replacement`. `text` and -/// `utf8` are needed to translate the byte-offset span into the LSP -/// `Range` the client expects. -pub fn text_edit_replace(span: Span, replacement: String, text: &str, utf8: bool) -> TextEdit { - TextEdit { - range: span_to_range(span, text, utf8), - new_text: replacement, - } -} - -/// Insert `content` at `pos`. The caller has already converted to LSP -/// coordinates — no further conversion is performed here. -pub fn text_edit_insert(pos: Position, content: String) -> TextEdit { - TextEdit { - range: Range { - start: pos, - end: pos, - }, - new_text: content, - } -} - -/// Delete the source covered by `span`. -pub fn text_edit_delete(span: Span, text: &str, utf8: bool) -> TextEdit { - text_edit_replace(span, String::new(), text, utf8) -} - -pub fn op_rename(old: Url, new: Url) -> DocumentChangeOperation { - DocumentChangeOperation::Op(ResourceOp::Rename(RenameFile { - old_uri: old, - new_uri: new, - options: None, - annotation_id: None, - })) -} - -pub fn op_delete(uri: Url) -> DocumentChangeOperation { - // lsp-types 0.94.x ships `DeleteFile` without `annotation_id`; later - // versions added it. Stick with the 0.94 shape until we move tower-lsp. - DocumentChangeOperation::Op(ResourceOp::Delete(DeleteFile { uri, options: None })) -} - -pub fn op_create(uri: Url) -> DocumentChangeOperation { - DocumentChangeOperation::Op(ResourceOp::Create(CreateFile { - uri, - options: None, - annotation_id: None, - })) -} - -#[derive(Default)] -pub struct WorkspaceEditBuilder { - changes: HashMap>, - ops: Vec, -} - -impl WorkspaceEditBuilder { - pub fn new() -> Self { - Self::default() - } - - pub fn edit(&mut self, uri: Url, edit: TextEdit) -> &mut Self { - self.changes.entry(uri).or_default().push(edit); - self - } - - pub fn file_op(&mut self, op: DocumentChangeOperation) -> &mut Self { - self.ops.push(op); - self - } - - pub fn is_empty(&self) -> bool { - self.changes.is_empty() && self.ops.is_empty() - } - - pub fn build(self) -> WorkspaceEdit { - // documentChanges is required whenever there are file ops (LSP - // doesn't represent renames/deletes/creates in the flat `changes` - // map). For pure text edits the older `changes` form keeps - // compatibility with LSP 3.15-clients. - if self.ops.is_empty() { - return WorkspaceEdit { - changes: Some(self.changes), - document_changes: None, - change_annotations: None, - }; - } - - let mut document_changes: Vec = self.ops; - // Edits land after file ops so the file exists when the text edit - // applies (e.g. create + initial content). - for (uri, edits) in self.changes { - document_changes.push(DocumentChangeOperation::Edit(TextDocumentEdit { - text_document: OptionalVersionedTextDocumentIdentifier { uri, version: None }, - edits: edits.into_iter().map(OneOf::Left).collect(), - })); - } - WorkspaceEdit { - changes: None, - document_changes: Some(DocumentChanges::Operations(document_changes)), - change_annotations: None, - } - } -} - -fn span_to_range(span: Span, text: &str, utf8: bool) -> Range { - let idx = LineIndex::new(text); - Range { - start: Position { - line: span.start.line, - character: char_at(span.start.line, span.start.column, text, &idx, utf8), - }, - end: Position { - line: span.end.line, - character: char_at(span.end.line, span.end.column, text, &idx, utf8), - }, - } -} - -fn char_at(line: u32, byte_col: u32, text: &str, idx: &LineIndex, utf8: bool) -> u32 { - if utf8 { - return byte_col; - } - let line_str = idx.line_str(line, text); - let upto = (byte_col as usize).min(line_str.len()); - line_str[..upto].chars().map(char::len_utf16).sum::() as u32 -} diff --git a/crates/nuwiki-lsp/src/export.rs b/crates/nuwiki-lsp/src/export.rs deleted file mode 100644 index 73c0cb6..0000000 --- a/crates/nuwiki-lsp/src/export.rs +++ /dev/null @@ -1,591 +0,0 @@ -//! HTML export — pure helpers shared by the `nuwiki.export.*` -//! command handlers. Side-effecting work (reading the template from -//! disk, writing the rendered HTML, copying CSS) lives in `commands.rs` -//! since the dispatcher is the one with a `&Backend` and async context. -//! -//! Design split: -//! - [`output_path_for`] / [`output_url_for`] — name → on-disk path. -//! - [`template_for`] — locate the right template file on disk. -//! - [`fallback_template`] — minimal stand-in when nothing is found. -//! - [`format_date`] — strftime subset (`%Y`/`%m`/`%d`). -//! - [`build_toc_html`] — TOC rendered into the `{{toc}}` variable. -//! - [`compute_root_path`] — relative path from a page's output to -//! `html_path` for stylesheet hrefs in nested pages. -//! - [`is_excluded`] — glob match against `exclude_files`. -//! - [`render_page_html`] — drives [`HtmlRenderer`] with all of the -//! above wired up; returns the final HTML string. - -use std::collections::HashMap; -use std::path::PathBuf; - -use nuwiki_core::ast::{BlockNode, DocumentNode, LinkKind}; -use nuwiki_core::date::DiaryDate; -use nuwiki_core::render::{HtmlRenderer, Renderer}; - -use crate::config::HtmlConfig; - -/// Map a page name (e.g. `"diary/2026-05-11"`) to its on-disk HTML -/// output path under `html_path`. Honours -/// [`HtmlConfig::html_filename_parameterization`] for URL-safe slugs -/// — when enabled, only the final path segment is slugified (so the -/// `diary/` subdir survives). -pub fn output_path_for(cfg: &HtmlConfig, name: &str) -> PathBuf { - let segments: Vec = name - .split('/') - .map(|s| { - if cfg.html_filename_parameterization { - slugify(s) - } else { - s.to_string() - } - }) - .collect(); - let mut p = cfg.html_path.clone(); - for (i, seg) in segments.iter().enumerate() { - if i + 1 == segments.len() { - p.push(format!("{seg}.html")); - } else { - p.push(seg); - } - } - p -} - -/// Locate the template body for a document. Resolution order: -/// 1. `/.` if set. -/// 2. `/.`. -/// 3. [`fallback_template`]. -/// -/// The on-disk read is performed by the caller (`commands.rs`) via the -/// [`TemplateSource`] returned here. Keeps this fn pure for tests. -pub fn template_for(cfg: &HtmlConfig, doc: &DocumentNode) -> TemplateSource { - let primary = doc.metadata.template.as_deref().map(|name| { - cfg.template_path - .join(format!("{name}{}", cfg.template_ext)) - }); - let fallback = cfg - .template_path - .join(format!("{}{}", cfg.template_default, cfg.template_ext)); - TemplateSource { primary, fallback } -} - -/// Two-tier lookup result. Caller tries `primary` first, then `fallback`, -/// then falls back to [`fallback_template`] when neither file exists. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TemplateSource { - pub primary: Option, - pub fallback: PathBuf, -} - -/// Minimal valid HTML page used when no template file is found on disk. -/// Includes the same `{{title}}` / `{{content}}` placeholders as a real -/// template so the renderer's substitution still works. -pub fn fallback_template(css_name: &str) -> String { - format!( - r#" - - - -{{{{title}}}} - - - -{{{{content}}}} - - -"# - ) -} - -/// Tiny strftime: supports `%Y` (4-digit year), `%m` (2-digit month), -/// `%d` (2-digit day), `%%` (literal percent). Anything else passes -/// through. The diary use case never produces time-of-day output, so -/// `%H`/`%M`/`%S` are intentionally omitted. -pub fn format_date(date: &DiaryDate, fmt: &str) -> String { - let mut out = String::with_capacity(fmt.len() + 4); - let bytes = fmt.as_bytes(); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' && i + 1 < bytes.len() { - match bytes[i + 1] { - b'Y' => out.push_str(&format!("{:04}", date.year)), - b'm' => out.push_str(&format!("{:02}", date.month)), - b'd' => out.push_str(&format!("{:02}", date.day)), - b'%' => out.push('%'), - other => { - out.push('%'); - out.push(other as char); - } - } - i += 2; - } else { - out.push(bytes[i] as char); - i += 1; - } - } - out -} - -/// Render the page's headings as a nested `
                ` for the `{{toc}}` -/// template variable. Returns an empty string when the document has -/// no headings. -pub fn build_toc_html(doc: &DocumentNode) -> String { - let headings = collect_headings(doc); - if headings.is_empty() { - return String::new(); - } - let min_level = headings.iter().map(|h| h.level).min().unwrap_or(1); - let mut out = String::from("
                  "); - let mut depth = 0usize; - for h in &headings { - let target = h.level.saturating_sub(min_level) as usize; - while depth < target { - out.push_str("
                    "); - depth += 1; - } - while depth > target { - out.push_str("
                  "); - depth -= 1; - } - // Anchor is the raw heading text (vimwiki scheme), HTML-escaped — must - // match the `id` the renderer puts on the heading element. - let anchor = escape_html(&h.title); - out.push_str(&format!("
                • {anchor}
                • ")); - } - while depth > 0 { - out.push_str("
                "); - depth -= 1; - } - out.push_str("
              "); - out -} - -/// Compute the relative path back to `html_path` from the directory -/// holding the output file for `name`. Used as the `{{root_path}}` -/// prefix so a nested page's ``/`\n"); - let c = cfg("/tmp/x"); - let html = export::render_page_html( - &ast, - Some("{{content}}".into()), - "Home", - DiaryDate::today_utc(), - &c.html, - None, - HashMap::new(), - ) - .unwrap(); - assert!(html.contains("H2O"), "sub verbatim: {html}"); - assert!(html.contains("Ctrl"), "kbd verbatim: {html}"); - assert!(html.contains("<script>"), "script escaped: {html}"); -} - -#[test] -fn render_page_html_ignore_newline_controls_soft_breaks() { - // Paragraph + list item, each with a soft (single) newline inside. - let ast = parse("alpha\nbeta\n\n- one\n two\n"); - // Default (true/true): soft breaks render as spaces, no
              . - let c = cfg("/tmp/x"); - let def = export::render_page_html( - &ast, - Some("{{content}}".into()), - "Home", - DiaryDate::today_utc(), - &c.html, - None, - HashMap::new(), - ) - .unwrap(); - assert!(!def.contains(" in both paragraph and list item. - let mut c2 = cfg("/tmp/x"); - c2.html.text_ignore_newline = false; - c2.html.list_ignore_newline = false; - let br = export::render_page_html( - &ast, - Some("{{content}}".into()), - "Home", - DiaryDate::today_utc(), - &c2.html, - None, - HashMap::new(), - ) - .unwrap(); - assert!(br.contains("alpha
              beta"), "para
              : {br}"); - assert!( - br.contains("one
              two") || br.contains("one
              two"), - "list
              : {br}" - ); -} - -#[test] -fn render_page_html_substitutes_emoji_when_enabled() { - let ast = parse("ship it :rocket: and :tada:\n"); - let c = cfg("/tmp/x"); // emoji_enable defaults true - let html = export::render_page_html( - &ast, - Some("{{content}}".into()), - "Home", - DiaryDate::today_utc(), - &c.html, - None, - HashMap::new(), - ) - .unwrap(); - assert!(html.contains("🚀"), "rocket: {html}"); - assert!(html.contains("🎉"), "tada: {html}"); - assert!(!html.contains(":rocket:"), "shortcode replaced: {html}"); -} - -#[test] -fn render_page_html_emoji_off_leaves_shortcodes() { - let ast = parse("plain :rocket: here\n"); - let mut c = cfg("/tmp/x"); - c.html.emoji_enable = false; - let html = export::render_page_html( - &ast, - Some("{{content}}".into()), - "Home", - DiaryDate::today_utc(), - &c.html, - None, - HashMap::new(), - ) - .unwrap(); - assert!(html.contains(":rocket:"), "left as-is: {html}"); -} - -#[test] -fn render_page_html_substitutes_title_content_and_extras() { - let ast = parse("%title My Page\n= One =\nbody text\n"); - let c = cfg("/tmp/x"); - let template = "{{title}}{{content}}
              {{date}}
              "; - let extras = HashMap::new(); - let html = export::render_page_html( - &ast, - Some(template.into()), - "Home", - DiaryDate::from_ymd(2026, 5, 11).unwrap(), - &c.html, - None, - extras, - ) - .unwrap(); - assert!(html.contains("My Page")); - assert!(html.contains("
              2026-05-11
              ")); - assert!(html.contains("body text")); -} - -#[test] -fn render_page_html_uses_metadata_date_when_set() { - let ast = parse("%date 2025-01-02\n= Hi =\n"); - let c = cfg("/tmp/x"); - let html = export::render_page_html( - &ast, - Some("DATE={{date}}".into()), - "Home", - DiaryDate::from_ymd(2026, 5, 11).unwrap(), - &c.html, - None, - HashMap::new(), - ) - .unwrap(); - assert!(html.contains("DATE=2025-01-02"), "got: {html}"); -} - -#[test] -fn render_page_html_numbers_headers_when_enabled() { - let ast = parse("= Intro =\n== Background ==\n== Methods ==\n= Results =\n"); - let mut c = cfg("/tmp/x"); - c.html.html_header_numbering = 1; // number from h1 - c.html.html_header_numbering_sym = ".".into(); - let html = export::render_page_html( - &ast, - None, - "Home", - DiaryDate::from_ymd(2026, 5, 11).unwrap(), - &c.html, - None, - HashMap::new(), - ) - .unwrap(); - assert!( - html.contains( - "" - ), - "got: {html}" - ); - assert!( - html.contains( - "" - ), - "got: {html}" - ); - assert!( - html.contains( - "" - ), - "got: {html}" - ); - assert!( - html.contains( - "" - ), - "got: {html}" - ); -} - -#[test] -fn render_page_html_numbering_skips_headers_above_start_level() { - let ast = parse("= Title =\n== Section ==\n=== Sub ===\n"); - let mut c = cfg("/tmp/x"); - c.html.html_header_numbering = 2; // start at h2; h1 stays unnumbered - // empty sym → number followed by a bare space - let html = export::render_page_html( - &ast, - None, - "Home", - DiaryDate::from_ymd(2026, 5, 11).unwrap(), - &c.html, - None, - HashMap::new(), - ) - .unwrap(); - assert!( - html.contains( - "" - ), - "h1 unnumbered, got: {html}" - ); - assert!( - html.contains( - "" - ), - "got: {html}" - ); - assert!( - html.contains( - "" - ), - "got: {html}" - ); -} - -#[test] -fn render_page_html_same_page_anchor_uses_current_filename() { - // vimwiki parity (#4): `[[#Section]]` resolves to `.html#Section`, - // not a bare `#Section`, where is the file being rendered. - let ast = parse("= Contents =\n\n[[#Contents]]\n"); - let c = cfg("/tmp/x"); - let html = export::render_page_html( - &ast, - None, - "index", - DiaryDate::from_ymd(2026, 5, 11).unwrap(), - &c.html, - None, - HashMap::new(), - ) - .unwrap(); - assert!( - html.contains(""), - "got: {html}" - ); -} - -#[test] -fn render_page_html_no_header_numbering_by_default() { - let ast = parse("= Intro =\n== Sub ==\n"); - let c = cfg("/tmp/x"); - let html = export::render_page_html( - &ast, - None, - "Home", - DiaryDate::from_ymd(2026, 5, 11).unwrap(), - &c.html, - None, - HashMap::new(), - ) - .unwrap(); - assert!( - html.contains( - "" - ), - "got: {html}" - ); - assert!( - html.contains( - "" - ), - "got: {html}" - ); -} - -#[test] -fn render_page_html_root_path_for_nested_pages() { - let ast = parse("= D =\n"); - let c = cfg("/tmp/x"); - let html = export::render_page_html( - &ast, - Some("CSS={{root_path}}{{css}}".into()), - "diary/2026-05-11", - DiaryDate::today_utc(), - &c.html, - None, - HashMap::new(), - ) - .unwrap(); - assert!(html.contains("CSS=../style.css"), "got: {html}"); -} - -#[test] -fn render_page_html_extra_var_overrides_default() { - let ast = parse("= H =\n"); - let c = cfg("/tmp/x"); - let mut extras = HashMap::new(); - extras.insert("date".into(), "OVERRIDE".into()); - let html = export::render_page_html( - &ast, - Some("D={{date}}".into()), - "H", - DiaryDate::today_utc(), - &c.html, - None, - extras, - ) - .unwrap(); - assert!(html.contains("D=OVERRIDE")); -} - -#[test] -fn render_page_html_substitutes_vimwiki_percent_template() { - // A stock vimwiki template (%title%/%content%/%root_path%/%wiki_css%/%date%) - // must render fully — every placeholder resolved, no literal `%…%` left. - let ast = parse("%title My Page\n= One =\nbody text\n"); - let c = cfg("/tmp/x"); - let template = "%title%\ -\ -%content%
              %date%
              "; - let html = export::render_page_html( - &ast, - Some(template.into()), - "diary/2026-05-11", - DiaryDate::from_ymd(2026, 5, 11).unwrap(), - &c.html, - None, - HashMap::new(), - ) - .unwrap(); - assert!(html.contains("My Page"), "title: {html}"); - assert!(html.contains("body text"), "content: {html}"); - assert!( - html.contains("href=\"../style.css\""), - "root_path+css: {html}" - ); - assert!(html.contains("
              2026-05-11
              "), "date: {html}"); - assert!(!html.contains('%'), "placeholder left behind: {html}"); -} - -#[test] -fn render_page_html_strips_wiki_extension_from_links() { - // A link written with the extension (`[[todo.wiki]]`) must export to - // `todo.html`, not `todo.wiki.html` — matching in-editor navigation. - let ast = parse("[[todo.wiki|Tasks]] and [[posts/index|Posts]]\n"); - let c = cfg("/tmp/x"); - let html = export::render_page_html( - &ast, - Some("{{content}}".into()), - "index", - DiaryDate::today_utc(), - &c.html, - Some(".wiki"), - HashMap::new(), - ) - .unwrap(); - assert!(html.contains("href=\"todo.html\""), "stripped: {html}"); - assert!(!html.contains("todo.wiki.html"), "not double-ext: {html}"); - // A link without the extension is unaffected. - assert!(html.contains("href=\"posts/index.html\""), "plain: {html}"); -} - -// ===== fallback_template + DEFAULT_CSS ===== - -#[test] -fn fallback_template_references_root_path_and_css() { - let t = export::fallback_template("custom.css"); - assert!(t.contains("{{title}}")); - assert!(t.contains("{{content}}")); - assert!(t.contains("{{root_path}}custom.css")); -} - -#[test] -fn default_css_is_non_empty() { - assert!(!export::DEFAULT_CSS.is_empty()); - assert!(export::DEFAULT_CSS.contains("font-family")); -} - -// ===== write_page (disk-touching) ===== - -#[test] -fn write_page_writes_html_to_disk() { - let root = temp_root("write"); - let mut c = WikiConfig::from_root(root.clone()); - c.html = HtmlConfig::from_root(&root); - let ast = parse("= Hello =\nworld\n"); - let outcome = nuwiki_lsp::commands::export_ops::write_page(&ast, "Hello", &c, false).unwrap(); - assert_eq!(outcome.page, "Hello"); - assert!(outcome.output_path.exists()); - let body = std::fs::read_to_string(&outcome.output_path).unwrap(); - assert!(body.contains("Hello")); -} - -#[test] -fn write_page_creates_subdirs() { - let root = temp_root("subdir"); - let mut c = WikiConfig::from_root(root.clone()); - c.html = HtmlConfig::from_root(&root); - let ast = parse("= Daily =\n"); - let outcome = - nuwiki_lsp::commands::export_ops::write_page(&ast, "diary/2026-05-11", &c, false).unwrap(); - assert!(outcome.output_path.exists()); - assert!(outcome - .output_path - .to_string_lossy() - .contains("diary/2026-05-11.html")); -} - -#[test] -fn write_page_uses_template_file_when_present() { - let root = temp_root("template"); - let mut c = WikiConfig::from_root(root.clone()); - c.html = HtmlConfig::from_root(&root); - // Write a template file the renderer should pick up. - std::fs::create_dir_all(&c.html.template_path).unwrap(); - std::fs::write( - c.html.template_path.join("default.tpl"), - "{{title}}|{{content}}", - ) - .unwrap(); - let ast = parse("%title T\n= H =\nbody\n"); - let outcome = nuwiki_lsp::commands::export_ops::write_page(&ast, "P", &c, false).unwrap(); - let body = std::fs::read_to_string(outcome.output_path).unwrap(); - assert!(body.starts_with("T|")); - assert!(body.ends_with("")); -} - -#[test] -fn ensure_css_writes_default_when_missing() { - let root = temp_root("css"); - let mut c = WikiConfig::from_root(root.clone()); - c.html = HtmlConfig::from_root(&root); - let res = nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap(); - assert!(res.created); - assert!(res.path.exists()); -} - -#[test] -fn ensure_css_is_idempotent_when_present() { - let root = temp_root("css2"); - let mut c = WikiConfig::from_root(root.clone()); - c.html = HtmlConfig::from_root(&root); - nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap(); - let res = nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap(); - assert!(!res.created); -} - -// ===== RSS ===== - -#[test] -fn write_rss_emits_valid_xml_with_entries() { - use nuwiki_lsp::diary::DiaryEntry; - use tower_lsp::lsp_types::Url; - - let root = temp_root("rss"); - let mut c = WikiConfig::from_root(root.clone()); - c.html = HtmlConfig::from_root(&root); - c.name = "Test Wiki".into(); - let entries = vec![ - DiaryEntry { - date: DiaryDate::from_ymd(2026, 5, 11).unwrap(), - uri: Url::parse("file:///tmp/diary/2026-05-11.wiki").unwrap(), - }, - DiaryEntry { - date: DiaryDate::from_ymd(2026, 5, 10).unwrap(), - uri: Url::parse("file:///tmp/diary/2026-05-10.wiki").unwrap(), - }, - ]; - let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap(); - let xml = std::fs::read_to_string(&path).unwrap(); - assert!(xml.starts_with("Test Wiki")); - // Newest first. - let i11 = xml.find("2026-05-11").unwrap(); - let i10 = xml.find("2026-05-10").unwrap(); - assert!(i11 < i10); - // No base_url → item links keep the file:// URI. - assert!(xml.contains("file:///tmp/diary/2026-05-11.wiki")); -} - -#[test] -fn write_rss_uses_base_url_for_public_links() { - use nuwiki_lsp::diary::DiaryEntry; - use tower_lsp::lsp_types::Url; - - let root = temp_root("rss_base"); - let mut c = WikiConfig::from_root(root.clone()); - c.html = HtmlConfig::from_root(&root); - c.html.base_url = "https://example.com/wiki/".into(); - c.diary_rel_path = "diary".into(); - let entries = vec![DiaryEntry { - date: DiaryDate::from_ymd(2026, 5, 11).unwrap(), - uri: Url::parse("file:///tmp/diary/2026-05-11.wiki").unwrap(), - }]; - let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap(); - let xml = std::fs::read_to_string(&path).unwrap(); - // Channel link points at the diary index page (upstream fidelity); item - // link is the public per-entry URL. - assert!( - xml.contains("https://example.com/wiki/diary/diary.html"), - "channel link: {xml}" - ); - assert!(xml.contains("https://example.com/wiki/diary/2026-05-11.html")); - // guid is the bare date, isPermaLink=false. - assert!( - xml.contains("2026-05-11"), - "guid: {xml}" - ); - // atom:link self + a pubDate are present. - assert!(xml.contains("rel=\"self\""), "atom:link: {xml}"); - assert!(xml.contains(""), "pubDate: {xml}"); - assert!(!xml.contains("file:///tmp/diary")); -} - -#[test] -fn prune_orphan_html_deletes_sourceless_keeps_protected() { - let root = temp_root("prune"); - let mut c = WikiConfig::from_root(root.clone()); - c.html = HtmlConfig::from_root(&root); - c.html.user_htmls = vec!["keep.html".into()]; - let html = &c.html.html_path; - std::fs::create_dir_all(html).unwrap(); - // A sourced page (Home.wiki exists) → kept. - std::fs::write(root.join("Home.wiki"), "= Home =\n").unwrap(); - std::fs::write(html.join("Home.html"), "

              Home

              ").unwrap(); - // An orphan (no source) → pruned. - std::fs::write(html.join("Ghost.html"), "

              Ghost

              ").unwrap(); - // A user_htmls-protected orphan → kept. - std::fs::write(html.join("keep.html"), "

              manual

              ").unwrap(); - // The CSS file → never pruned. - std::fs::write(html.join(&c.html.css_name), "body{}").unwrap(); - - let pruned = nuwiki_lsp::commands::export_ops::prune_orphan_html(&c); - assert_eq!(pruned.len(), 1, "only the orphan: {pruned:?}"); - assert!(!html.join("Ghost.html").exists(), "orphan deleted"); - assert!(html.join("Home.html").exists(), "sourced kept"); - assert!(html.join("keep.html").exists(), "user_htmls kept"); - assert!(html.join(&c.html.css_name).exists(), "css kept"); -} - -#[test] -fn write_rss_honours_rss_name_and_max_items() { - use nuwiki_lsp::diary::DiaryEntry; - use tower_lsp::lsp_types::Url; - let root = temp_root("rss_cap"); - let mut c = WikiConfig::from_root(root.clone()); - c.html = HtmlConfig::from_root(&root); - c.html.rss_name = "feed.xml".into(); - c.html.rss_max_items = 2; - let entries: Vec = (1..=5) - .map(|d| DiaryEntry { - date: DiaryDate::from_ymd(2026, 5, d).unwrap(), - uri: Url::parse(&format!("file:///tmp/diary/2026-05-0{d}.wiki")).unwrap(), - }) - .collect(); - let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap(); - assert!(path.ends_with("feed.xml"), "rss_name: {path:?}"); - let xml = std::fs::read_to_string(&path).unwrap(); - // Capped at 2 items (newest first: 05-05, 05-04). - assert_eq!(xml.matches("").count(), 2, "cap: {xml}"); - assert!(xml.contains("2026-05-05") && xml.contains("2026-05-04")); - assert!(!xml.contains("2026-05-03"), "older items dropped: {xml}"); -} - -#[test] -fn write_page_invokes_custom_wiki2html_converter() { - let root = temp_root("custom_w2h"); - let mut c = WikiConfig::from_root(root.clone()); - c.html = HtmlConfig::from_root(&root); - // Source file must exist on disk — the converter receives its path. - std::fs::write(root.join("Hello.wiki"), "= Hello =\nworld\n").unwrap(); - // A tiny shell "converter": writes a marker into the output path it is told - // to produce. nuwiki appends args positionally after `_` ($0), so - // $1=force, $2=syntax, $3=ext, $4=output_dir, $5=input_file, … - let script = "mkdir -p \"$4\" && printf 'CUSTOM:%s' \"$5\" > \"$4\"/Hello.html"; - c.html.custom_wiki2html = format!("sh -c {} _", shell_words_quote(script)); - let outcome = - nuwiki_lsp::commands::export_ops::write_page(&parse("= Hello =\n"), "Hello", &c, true) - .unwrap(); - assert_eq!(outcome.page, "Hello"); - assert!( - outcome.output_path.exists(), - "converter must produce output" - ); - let body = std::fs::read_to_string(&outcome.output_path).unwrap(); - assert!( - body.starts_with("CUSTOM:"), - "marker from converter, got {body}" - ); -} - -/// Minimal single-quote shell escaping for the test command above. -fn shell_words_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) -} - -// ===== COMMANDS list completeness ===== - -#[test] -fn commands_list_includes_export_entries() { - let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); - for name in [ - "nuwiki.export.currentToHtml", - "nuwiki.export.allToHtml", - "nuwiki.export.allToHtmlForce", - "nuwiki.export.browse", - "nuwiki.export.rss", - ] { - assert!(names.contains(&name), "missing: {name}"); - } -} diff --git a/crates/nuwiki-lsp/tests/index_and_config.rs b/crates/nuwiki-lsp/tests/index_and_config.rs deleted file mode 100644 index 811e6eb..0000000 --- a/crates/nuwiki-lsp/tests/index_and_config.rs +++ /dev/null @@ -1,693 +0,0 @@ -//! LSP-side workspace plumbing: -//! - `WorkspaceEditBuilder` (changes-map vs document-changes promotion) -//! and `TextEdit` UTF-8/UTF-16 conversion. -//! - `Config` desugar from `initializationOptions` JSON (per-wiki keys, -//! legacy single-wiki shape). -//! - `Wiki` resolution: building wikis from raw config and matching -//! URIs to the longest-prefix wiki root. -//! - `collect_diagnostics` composition (parser errors + link-health). - -use std::path::PathBuf; - -use nuwiki_core::ast::{ - inline::InlineNode, BlockNode, DocumentNode, ErrorNode, ParagraphNode, Position as AstPosition, - Span, TextNode, -}; -use nuwiki_lsp::collect_diagnostics; -use nuwiki_lsp::config::{config_from_json, Config, DiagnosticConfig, LinkSeverity, WikiConfig}; -use nuwiki_lsp::edits::{ - op_create, op_delete, op_rename, text_edit_delete, text_edit_insert, text_edit_replace, - WorkspaceEditBuilder, -}; -use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki, Wiki, WikiId}; -use serde_json::json; -use tower_lsp::lsp_types::{DocumentChanges, Position as LspPosition, Url}; - -// ===== Edits ===== - -fn span_one_line(byte_col: u32, len: u32) -> Span { - Span::new( - AstPosition { - line: 0, - column: byte_col, - offset: byte_col as usize, - }, - AstPosition { - line: 0, - column: byte_col + len, - offset: (byte_col + len) as usize, - }, - ) -} - -#[test] -fn text_edit_replace_passthrough_in_utf8() { - let edit = text_edit_replace(span_one_line(0, 5), "world".into(), "hello there", true); - assert_eq!(edit.range.start.character, 0); - assert_eq!(edit.range.end.character, 5); - assert_eq!(edit.new_text, "world"); -} - -#[test] -fn text_edit_replace_converts_to_utf16_for_multibyte() { - // "héllo" — h(1B) é(2B) l(1B) l(1B) o(1B); UTF-16 chars: 1+1+1+1+1=5. - // span covers bytes 0..3 (h + é); UTF-16 character count = 2. - let edit = text_edit_replace(span_one_line(0, 3), "X".into(), "héllo", false); - assert_eq!(edit.range.start.character, 0); - assert_eq!(edit.range.end.character, 2); -} - -#[test] -fn text_edit_insert_produces_zero_width_range() { - let edit = text_edit_insert( - LspPosition { - line: 0, - character: 4, - }, - "X".into(), - ); - assert_eq!(edit.range.start, edit.range.end); - assert_eq!(edit.new_text, "X"); -} - -#[test] -fn text_edit_delete_has_empty_new_text() { - let edit = text_edit_delete(span_one_line(0, 3), "abcdef", true); - assert_eq!(edit.new_text, ""); - assert_eq!(edit.range.end.character, 3); -} - -#[test] -fn builder_with_only_edits_uses_changes_map() { - let uri = Url::parse("file:///tmp/a.wiki").unwrap(); - let mut b = WorkspaceEditBuilder::new(); - b.edit( - uri.clone(), - text_edit_replace(span_one_line(0, 3), "new".into(), "old text", true), - ); - let we = b.build(); - assert!(we.changes.is_some()); - assert!(we.document_changes.is_none()); - let changes = we.changes.unwrap(); - assert_eq!(changes.len(), 1); - assert_eq!(changes[&uri].len(), 1); -} - -#[test] -fn builder_with_file_op_promotes_to_document_changes() { - let old = Url::parse("file:///tmp/A.wiki").unwrap(); - let new = Url::parse("file:///tmp/B.wiki").unwrap(); - let mut b = WorkspaceEditBuilder::new(); - b.file_op(op_rename(old.clone(), new.clone())); - b.edit( - new.clone(), - text_edit_replace(span_one_line(0, 1), "X".into(), "Y", true), - ); - let we = b.build(); - assert!(we.changes.is_none()); - let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else { - panic!("expected Operations variant"); - }; - // Rename comes first (file ops always precede content edits in the - // builder), then the text edit on the renamed file. - assert_eq!(ops.len(), 2); -} - -#[test] -fn builder_is_empty_until_something_pushed() { - let mut b = WorkspaceEditBuilder::new(); - assert!(b.is_empty()); - b.file_op(op_delete(Url::parse("file:///x").unwrap())); - assert!(!b.is_empty()); -} - -#[test] -fn op_create_round_trips_through_builder() { - let uri = Url::parse("file:///tmp/new.wiki").unwrap(); - let mut b = WorkspaceEditBuilder::new(); - b.file_op(op_create(uri)); - let we = b.build(); - assert!(we.document_changes.is_some()); -} - -// ===== Config ===== - -#[test] -fn config_default_is_empty() { - let c = Config::default(); - assert!(c.wikis.is_empty()); - assert_eq!(c.diagnostic.link_severity, LinkSeverity::Warning); -} - -#[test] -fn v1_0_single_wiki_desugars_to_one_entry() { - let cfg = config_from_json(json!({ - "wiki_root": "/tmp/vimwiki", - "file_extension": ".wiki", - "syntax": "vimwiki", - })); - assert_eq!(cfg.wikis.len(), 1); - assert_eq!(cfg.wikis[0].root, PathBuf::from("/tmp/vimwiki")); - assert_eq!(cfg.wikis[0].file_extension, ".wiki"); - assert_eq!(cfg.wikis[0].syntax, "vimwiki"); -} - -#[test] -fn single_wiki_shorthand_honours_top_level_per_wiki_keys() { - // Regression: a single-wiki user sets per-wiki keys at the top level - // (alongside wiki_root); they must reach the synthesized wiki, not just - // file_extension/syntax. Previously toc_header_level etc. were dropped. - let cfg = config_from_json(json!({ - "wiki_root": "/tmp/vimwiki", - "toc_header": "Table of Contents", - "toc_header_level": 2, - "links_header_level": 3, - "auto_export": true, - "html_path": "/tmp/out", - })); - assert_eq!(cfg.wikis.len(), 1); - let w = &cfg.wikis[0]; - assert_eq!(w.toc_header, "Table of Contents"); - assert_eq!(w.toc_header_level, 2); - assert_eq!(w.links_header_level, 3); - assert!(w.html.auto_export); - assert_eq!(w.html.html_path, PathBuf::from("/tmp/out")); -} - -#[test] -fn explicit_wikis_list_overrides_legacy_shape() { - let cfg = config_from_json(json!({ - "wiki_root": "/tmp/IGNORED", - "wikis": [ - { "root": "/tmp/personal", "name": "personal" }, - { "root": "/tmp/work", "syntax": "vimwiki" }, - ], - })); - assert_eq!(cfg.wikis.len(), 2); - assert_eq!(cfg.wikis[0].name, "personal"); - assert_eq!(cfg.wikis[1].root, PathBuf::from("/tmp/work")); -} - -#[test] -fn empty_json_yields_no_wikis() { - let cfg = config_from_json(json!({})); - assert!(cfg.wikis.is_empty()); -} - -#[test] -fn invalid_json_leaves_existing_config_unchanged() { - let mut cfg = Config::default(); - cfg.wikis - .push(WikiConfig::from_root(PathBuf::from("/tmp/a"))); - cfg.apply_change(&json!("not a config object")); - // Existing wikis still present. - assert_eq!(cfg.wikis.len(), 1); -} - -// ===== Wiki aggregate ===== - -fn wiki_at(id: u32, root: &str) -> Wiki { - Wiki::new(WikiId(id), WikiConfig::from_root(PathBuf::from(root))) -} - -#[test] -fn build_wikis_assigns_sequential_ids() { - let configs = vec![ - WikiConfig::from_root(PathBuf::from("/tmp/a")), - WikiConfig::from_root(PathBuf::from("/tmp/b")), - ]; - let wikis = build_wikis(&configs); - assert_eq!(wikis.len(), 2); - assert_eq!(wikis[0].id, WikiId(0)); - assert_eq!(wikis[1].id, WikiId(1)); -} - -#[test] -fn resolve_uri_to_wiki_picks_longest_prefix() { - let wikis = vec![wiki_at(0, "/wiki"), wiki_at(1, "/wiki/sub")]; - let uri = Url::from_file_path("/wiki/sub/page.wiki").unwrap(); - assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1))); -} - -#[test] -fn resolve_uri_to_wiki_misses_outside_any_root() { - let wikis = vec![wiki_at(0, "/wiki")]; - let uri = Url::from_file_path("/other/page.wiki").unwrap(); - assert!(resolve_uri_to_wiki(&wikis, &uri).is_none()); -} - -#[test] -fn wiki_contains_respects_root_prefix() { - let w = wiki_at(0, "/wiki"); - assert!(w.contains(&Url::from_file_path("/wiki/note.wiki").unwrap())); - assert!(!w.contains(&Url::from_file_path("/other/note.wiki").unwrap())); -} - -// ===== Diagnostics composition ===== - -#[test] -fn collect_diagnostics_emits_one_per_error_node() { - let doc = DocumentNode { - span: Span::default(), - metadata: Default::default(), - children: vec![ - BlockNode::Error(ErrorNode { - span: span_one_line(0, 3), - raw: "oops".into(), - message: "broken".into(), - }), - BlockNode::Paragraph(ParagraphNode { - span: Span::default(), - children: vec![InlineNode::Text(TextNode { - span: Span::default(), - content: "ok".into(), - })], - }), - ], - }; - let cfg = DiagnosticConfig::default(); - let diags = collect_diagnostics(&doc, "abc ok", None, None, None, &cfg, true); - assert_eq!(diags.len(), 1); - assert_eq!(diags[0].message, "broken"); -} - -#[test] -fn collect_diagnostics_link_health_emits_nothing_without_index() { - // No index → link-health source is skipped, even when severity is on. - let doc = DocumentNode::default(); - let cfg = DiagnosticConfig { - link_severity: LinkSeverity::Error, - }; - let diags = collect_diagnostics(&doc, "", None, None, Some("Home"), &cfg, true); - assert!(diags.is_empty()); -} - -// ===== Per-wiki config defaults + JSON parsing ===== - -#[test] -fn defaults_carry_vimwiki_per_wiki_keys() { - let cfg = WikiConfig::empty(); - assert_eq!(cfg.index, "index"); - assert_eq!(cfg.diary_frequency, "daily"); - assert_eq!(cfg.diary_caption_level, 0); // upstream default - assert_eq!(cfg.diary_sort, "desc"); - assert_eq!(cfg.diary_header, "Diary"); - // diary_months defaults to the 12 English month names. - assert_eq!(cfg.diary_months.len(), 12); - assert_eq!(cfg.diary_months[0], "January"); - assert_eq!(cfg.diary_months[11], "December"); - assert_eq!(cfg.listsyms, " .oOX"); - assert!(cfg.listsyms_propagate); - assert_eq!(cfg.list_margin, -1); - assert_eq!(cfg.links_space_char, " "); - assert!(!cfg.auto_toc); - assert!(!cfg.auto_generate_links); - assert!(!cfg.auto_generate_tags); - assert!(!cfg.auto_diary_index); - // Generated-section captions match upstream defaults. - assert_eq!(cfg.toc_header, "Contents"); - assert_eq!(cfg.toc_header_level, 1); - assert_eq!(cfg.links_header, "Generated Links"); - assert_eq!(cfg.tags_header, "Generated Tags"); - assert_eq!(cfg.listsym_rejected, "-"); - // No external converter / public base URL by default. - assert_eq!(cfg.html.custom_wiki2html, ""); - assert_eq!(cfg.html.custom_wiki2html_args, ""); - assert_eq!(cfg.html.base_url, ""); - // HTML section numbering is off by default (vimwiki's `0`). - assert_eq!(cfg.html.html_header_numbering, 0); - assert_eq!(cfg.html.html_header_numbering_sym, ""); - // New config defaults match upstream. - assert!(cfg.create_link); - assert_eq!(cfg.dir_link, ""); - assert_eq!(cfg.bullet_types, vec!["-", "*", "#"]); - assert!(!cfg.cycle_bullets); - assert!(!cfg.generated_links_caption); - assert_eq!(cfg.toc_link_format, 0); - assert!(!cfg.table_reduce_last_col); - assert_eq!(cfg.html.rss_name, "rss.xml"); - assert_eq!(cfg.html.rss_max_items, 10); - assert!(cfg.html.list_ignore_newline); - assert!(cfg.html.text_ignore_newline); - assert!(cfg.html.emoji_enable); - assert!(cfg.html.user_htmls.is_empty()); - assert!(cfg.html.valid_html_tags.contains(&"sub".to_string())); - // color_dic ships a populated palette (vimwiki seeds one). - assert!(cfg.html.color_dic.contains_key("red")); -} - -#[test] -fn raw_wiki_parses_config_batch_keys() { - let cfg = config_from_json(serde_json::json!({ - "wikis": [{ - "root": "/tmp/w", - "create_link": false, - "dir_link": "index", - "bullet_types": ["+", "-"], - "cycle_bullets": true, - "generated_links_caption": 1, - "toc_link_format": 1, - "table_reduce_last_col": true, - "rss_name": "feed.xml", - "rss_max_items": 5, - "valid_html_tags": "b,mark", - "list_ignore_newline": 0, - "text_ignore_newline": false, - "emoji_enable": 0, - "user_htmls": ["404.html"], - "color_tag_template": "__CONTENT__", - }], - })); - let w = &cfg.wikis[0]; - assert!(!w.create_link); - assert_eq!(w.dir_link, "index"); - assert_eq!(w.bullet_types, vec!["+", "-"]); - assert!(w.cycle_bullets); - assert!(w.generated_links_caption); - assert_eq!(w.toc_link_format, 1); - assert!(w.table_reduce_last_col); - assert_eq!(w.html.rss_name, "feed.xml"); - assert_eq!(w.html.rss_max_items, 5); - assert_eq!(w.html.valid_html_tags, vec!["b", "mark"]); - assert!(!w.html.list_ignore_newline); - assert!(!w.html.text_ignore_newline); - assert!(!w.html.emoji_enable); - assert_eq!(w.html.user_htmls, vec!["404.html"]); - assert_eq!(w.html.color_tag_template, "__CONTENT__"); -} - -#[test] -fn raw_wiki_parses_every_new_key() { - let cfg = config_from_json(serde_json::json!({ - "wikis": [{ - "root": "/tmp/w", - "index": "home", - "diary_frequency": "weekly", - "diary_weekly_style": "date", - "diary_start_week_day": "sunday", - "diary_caption_level": 2, - "diary_sort": "asc", - "diary_header": "Journal", - "diary_months": ["Jan","Feb","Mar","Apr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"], - "listsyms": "abcde", - "listsym_rejected": "✗", - "listsyms_propagate": false, - "list_margin": 2, - "links_space_char": "_", - "auto_toc": true, - "auto_generate_links": true, - "auto_generate_tags": 1, - "auto_diary_index": true, - "toc_header": "Table of Contents", - "toc_header_level": 2, - "links_header": "All Pages", - "tags_header": "Tag Index", - "tags_header_level": 3, - "custom_wiki2html": "~/bin/my_wiki2html.sh", - "custom_wiki2html_args": "--flag", - "base_url": "https://example.com/wiki/", - "html_header_numbering": 2, - "html_header_numbering_sym": ".", - }], - })); - let w = &cfg.wikis[0]; - assert_eq!(w.index, "home"); - assert_eq!(w.diary_frequency, "weekly"); - assert_eq!(w.diary_weekly_style, "date"); - assert_eq!(w.diary_start_week_day, "sunday"); - assert_eq!(w.diary_caption_level, 2); - assert_eq!(w.diary_sort, "asc"); - assert_eq!(w.diary_header, "Journal"); - assert_eq!(w.diary_months[4], "Mai"); - assert_eq!(w.diary_months[11], "Dez"); - assert_eq!(w.listsyms, "abcde"); - assert_eq!(w.listsym_rejected, "✗"); - assert_eq!(w.list_syms().rejected_glyph(), '✗'); - assert!(!w.listsyms_propagate); - assert_eq!(w.list_margin, 2); - assert_eq!(w.links_space_char, "_"); - assert!(w.auto_toc); - assert!(w.auto_generate_links); - assert!(w.auto_generate_tags); // int 1 → true - assert!(w.auto_diary_index); - assert_eq!(w.toc_header, "Table of Contents"); - assert_eq!(w.toc_header_level, 2); - assert_eq!(w.links_header, "All Pages"); - assert_eq!(w.links_header_level, 1); // unspecified → default - assert_eq!(w.tags_header, "Tag Index"); - assert_eq!(w.tags_header_level, 3); - // custom_wiki2html keeps the literal command string (tilde-expansion is - // the converter's job, not ours); args + base_url round-trip verbatim. - assert_eq!(w.html.custom_wiki2html, "~/bin/my_wiki2html.sh"); - assert_eq!(w.html.custom_wiki2html_args, "--flag"); - assert_eq!(w.html.base_url, "https://example.com/wiki/"); - assert_eq!(w.html.html_header_numbering, 2); - assert_eq!(w.html.html_header_numbering_sym, "."); - - // The parsed keys drive a date-mode, Sunday-start diary calendar: - // a weekly note is the week-start date (YYYY-MM-DD), stepping ±7 days. - use nuwiki_core::date::{DiaryDate, DiaryPeriod}; - let cal = w.diary_calendar(); - let wed = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 27).unwrap()); - assert_eq!( - cal.next(wed), - DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 31).unwrap()) - ); -} - -#[test] -fn markdown_wiki_derives_list_margin_zero_when_unset() { - // vimwiki sets list_margin = 0 for markdown wikis (vs -1 elsewhere) when - // the key is absent; an explicit value still wins. - let cfg = config_from_json(serde_json::json!({ - "wikis": [{ "root": "/tmp/md", "syntax": "markdown" }], - })); - assert_eq!(cfg.wikis[0].list_margin, 0); - - let vw = config_from_json(serde_json::json!({ - "wikis": [{ "root": "/tmp/vw", "syntax": "vimwiki" }], - })); - assert_eq!(vw.wikis[0].list_margin, -1); - - let explicit = config_from_json(serde_json::json!({ - "wikis": [{ "root": "/tmp/md2", "syntax": "markdown", "list_margin": 4 }], - })); - assert_eq!(explicit.wikis[0].list_margin, 4); -} - -#[test] -fn diary_caption_level_accepts_negative() { - // vimwiki allows diary_caption_level = -1 (min: -1); the field is i8 so it - // parses instead of failing deserialization. - let cfg = config_from_json(serde_json::json!({ - "wikis": [{ "root": "/tmp/w", "diary_caption_level": -1 }], - })); - assert_eq!(cfg.wikis[0].diary_caption_level, -1); -} - -#[test] -fn diary_calendar_defaults_to_iso_weeks() { - let cfg = WikiConfig::empty(); - assert_eq!(cfg.diary_weekly_style, "iso"); - assert_eq!(cfg.diary_start_week_day, "monday"); -} - -#[test] -fn link_severity_parses_from_client_config() { - for (input, expected) in [ - ("off", LinkSeverity::Off), - ("hint", LinkSeverity::Hint), - ("warn", LinkSeverity::Warning), - ("warning", LinkSeverity::Warning), - ("error", LinkSeverity::Error), - ("ERROR", LinkSeverity::Error), - ] { - let cfg = config_from_json(json!({ - "wiki_root": "/tmp/w", - "diagnostic": { "link_severity": input }, - })); - assert_eq!( - cfg.diagnostic.link_severity, expected, - "input {input:?} should map to {expected:?}" - ); - } -} - -#[test] -fn link_severity_invalid_or_absent_falls_back_to_default() { - // Unknown string → default (Warning). - let bad = config_from_json(json!({ - "wiki_root": "/tmp/w", - "diagnostic": { "link_severity": "loud" }, - })); - assert_eq!(bad.diagnostic.link_severity, LinkSeverity::Warning); - - // No diagnostic key at all → default. - let none = config_from_json(json!({ "wiki_root": "/tmp/w" })); - assert_eq!(none.diagnostic.link_severity, LinkSeverity::Warning); -} - -#[test] -fn apply_change_preserves_severity_on_partial_update() { - let mut cfg = config_from_json(json!({ - "wiki_root": "/tmp/w", - "diagnostic": { "link_severity": "error" }, - })); - assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Error); - - // A later update that omits `diagnostic` must not reset severity. - cfg.apply_change(&json!({ "wiki_root": "/tmp/w2" })); - assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Error); -} - -#[test] -fn apply_change_minimal_diagnostic_payload_is_not_unwrapped() { - // A lone `{ "diagnostic": { … } }` is a real flat payload, not a - // `{ "nuwiki": { … } }` namespace wrapper. The single-key unwrap must - // skip it (it's a recognised top-level key) so the severity applies. - let mut cfg = config_from_json(json!({ "wiki_root": "/tmp/w" })); - assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Warning); - - cfg.apply_change(&json!({ "diagnostic": { "link_severity": "error" } })); - assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Error); - - // The Neovim namespace wrapper still unwraps correctly. - cfg.apply_change(&json!({ "nuwiki": { "diagnostic": { "link_severity": "hint" } } })); - assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Hint); -} - -#[test] -fn severity_change_via_apply_change_is_observable_in_collected_diagnostics() { - // Contract behind `did_change_configuration`'s diagnostic re-publish: - // once `apply_change` updates the severity, re-running `collect_diagnostics` - // (what the handler does for every open doc) must emit broken-link - // diagnostics at the *new* severity. Guards the data path the handler - // depends on — apply_change writing the severity AND collect_diagnostics - // reading it. - use nuwiki_core::syntax::vimwiki::VimwikiSyntax; - use nuwiki_core::syntax::SyntaxPlugin; - use nuwiki_lsp::index::WorkspaceIndex; - use tower_lsp::lsp_types::DiagnosticSeverity; - - let root = "/tmp/cfgchange"; - let src = "[[GhostPage]]\n"; - let ast = VimwikiSyntax::new().parse(src); - let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root))); - let uri = Url::from_file_path(format!("{root}/Home.wiki")).unwrap(); - idx.upsert(uri.clone(), &ast); - - let mut cfg = config_from_json(json!({ - "wiki_root": root, - "diagnostic": { "link_severity": "warn" }, - })); - let warn = collect_diagnostics( - &ast, - src, - Some(&uri), - Some(&idx), - Some("Home"), - &cfg.diagnostic, - true, - ); - assert_eq!(warn.len(), 1, "broken link should warn"); - assert_eq!(warn[0].severity, Some(DiagnosticSeverity::WARNING)); - - // Simulate the client raising severity via didChangeConfiguration. - cfg.apply_change(&json!({ "diagnostic": { "link_severity": "error" } })); - let err = collect_diagnostics( - &ast, - src, - Some(&uri), - Some(&idx), - Some("Home"), - &cfg.diagnostic, - true, - ); - assert_eq!(err.len(), 1); - assert_eq!( - err[0].severity, - Some(DiagnosticSeverity::ERROR), - "re-collected diagnostics must reflect the new severity" - ); -} - -#[test] -fn vim_flat_and_neovim_wrapped_payloads_desugar_identically() { - // Parity guard for the two client shapes: - // * Vim (`autoload/nuwiki/lsp.vim`) → flat init_options dict. - // * Neovim (`lua/nuwiki/lsp.lua`) → `{ nuwiki = { … } }` for - // `workspace/didChangeConfiguration` (server_settings()). - // Both must produce the same wikis; otherwise switching editors would - // silently change how the server resolves links/diary/HTML paths. - let inner = json!({ - "wiki_root": "/tmp/base", - "file_extension": ".md", - "syntax": "vimwiki", - "log_level": "debug", - "diagnostic": { "link_severity": "error" }, - "wikis": [ - { - "name": "personal", - "root": "/tmp/personal", - "file_extension": ".wiki", - "index": "home", - "diary_rel_path": "journal", - }, - { "name": "work", "root": "/tmp/work" }, - ], - }); - - let flat = config_from_json(inner.clone()); - let wrapped = config_from_json(json!({ "nuwiki": inner })); - - assert_eq!( - flat.diagnostic.link_severity, - wrapped.diagnostic.link_severity - ); - assert_eq!(flat.diagnostic.link_severity, LinkSeverity::Error); - assert_eq!(flat.wikis.len(), wrapped.wikis.len()); - for (a, b) in flat.wikis.iter().zip(wrapped.wikis.iter()) { - assert_eq!(a.name, b.name); - assert_eq!(a.root, b.root); - assert_eq!(a.file_extension, b.file_extension); - assert_eq!(a.syntax, b.syntax); - assert_eq!(a.index, b.index); - assert_eq!(a.diary_rel_path, b.diary_rel_path); - } - - // And the desugared list matches the per-wiki values both clients send. - assert_eq!(flat.wikis[0].name, "personal"); - assert_eq!(flat.wikis[0].root, PathBuf::from("/tmp/personal")); - assert_eq!(flat.wikis[0].file_extension, ".wiki"); - assert_eq!(flat.wikis[0].index, "home"); - assert_eq!(flat.wikis[0].diary_rel_path, "journal"); - assert_eq!(flat.wikis[1].name, "work"); - assert_eq!(flat.wikis[1].root, PathBuf::from("/tmp/work")); -} - -#[test] -fn legacy_single_wiki_shape_picks_up_defaults() { - let cfg = config_from_json(serde_json::json!({ - "wiki_root": "/tmp/legacy", - })); - assert_eq!(cfg.wikis.len(), 1); - assert_eq!(cfg.wikis[0].index, "index"); - assert_eq!(cfg.wikis[0].diary_frequency, "daily"); -} - -#[test] -fn vimwiki_compat_payload_sets_toc_level_per_wiki() { - // The exact shape the Vim client emits when translating g:vimwiki_list + - // g:vimwiki_toc_header_level=2 (drop-in vimwiki compat). - let cfg = config_from_json(json!({ - "wiki_root": "~/vimwiki", - "wikis": [ - { "root": "/tmp/personal", "toc_header_level": 2, "html_header_numbering": 2 }, - { "root": "/tmp/ifood", "toc_header_level": 2 }, - ], - })); - assert_eq!(cfg.wikis.len(), 2); - assert_eq!(cfg.wikis[0].toc_header_level, 2); - assert_eq!(cfg.wikis[0].html.html_header_numbering, 2); - assert_eq!(cfg.wikis[1].toc_header_level, 2); -} diff --git a/crates/nuwiki-lsp/tests/link_health.rs b/crates/nuwiki-lsp/tests/link_health.rs deleted file mode 100644 index 2871685..0000000 --- a/crates/nuwiki-lsp/tests/link_health.rs +++ /dev/null @@ -1,898 +0,0 @@ -//! Link-health diagnostics + TOC/links/orphans queries. - -use std::path::PathBuf; - -use nuwiki_core::syntax::vimwiki::VimwikiSyntax; -use nuwiki_core::syntax::SyntaxPlugin; -use nuwiki_lsp::commands::ops; -use nuwiki_lsp::config::LinkSeverity; -use nuwiki_lsp::diagnostics::{self, BrokenLinkKind}; -use nuwiki_lsp::index::WorkspaceIndex; -use tower_lsp::lsp_types::{DiagnosticSeverity, Url}; - -fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { - VimwikiSyntax::new().parse(src) -} - -fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex { - let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root))); - for (name, src) in pages { - let ast = parse(src); - let path = format!("{root}/{name}.wiki"); - let uri = Url::from_file_path(&path).unwrap(); - idx.upsert(uri, &ast); - } - idx -} - -fn home_uri(root: &str) -> Url { - Url::from_file_path(format!("{root}/Home.wiki")).unwrap() -} - -// ===== severity_to_lsp ===== - -#[test] -fn severity_off_returns_none() { - assert!(diagnostics::severity_to_lsp(LinkSeverity::Off).is_none()); -} - -#[test] -fn severity_levels_round_trip() { - assert_eq!( - diagnostics::severity_to_lsp(LinkSeverity::Hint), - Some(DiagnosticSeverity::HINT) - ); - assert_eq!( - diagnostics::severity_to_lsp(LinkSeverity::Warning), - Some(DiagnosticSeverity::WARNING) - ); - assert_eq!( - diagnostics::severity_to_lsp(LinkSeverity::Error), - Some(DiagnosticSeverity::ERROR) - ); -} - -// ===== link_health: wiki targets ===== - -#[test] -fn link_to_missing_page_warns() { - let root = "/tmp/lh1"; - let idx = build_index(root, &[("Home", "[[GhostPage]]\n")]); - let src = "[[GhostPage]]\n"; - let ast = parse(src); - let uri = home_uri(root); - let diags = diagnostics::link_health( - &ast, - src, - Some(&uri), - &idx, - "Home", - LinkSeverity::Warning, - true, - ); - assert_eq!(diags.len(), 1); - assert!(diags[0].message.contains("GhostPage")); - assert_eq!(diags[0].severity, Some(DiagnosticSeverity::WARNING)); -} - -#[test] -fn link_to_existing_page_silent() { - let root = "/tmp/lh2"; - let idx = build_index(root, &[("Home", "[[Other]]\n"), ("Other", "= Other =\n")]); - let src = "[[Other]]\n"; - let ast = parse(src); - let diags = diagnostics::link_health( - &ast, - src, - Some(&home_uri(root)), - &idx, - "Home", - LinkSeverity::Warning, - true, - ); - assert!(diags.is_empty(), "got: {:?}", diags); -} - -#[test] -fn link_with_valid_anchor_silent() { - let root = "/tmp/lh3"; - let idx = build_index( - root, - &[ - ("Home", "[[Target#section-one]]\n"), - ("Target", "= Target =\n== Section One ==\n"), - ], - ); - let src = "[[Target#section-one]]\n"; - let ast = parse(src); - let diags = diagnostics::link_health( - &ast, - src, - Some(&home_uri(root)), - &idx, - "Home", - LinkSeverity::Warning, - true, - ); - assert!(diags.is_empty(), "got: {:?}", diags); -} - -#[test] -fn link_with_missing_anchor_warns() { - let root = "/tmp/lh4"; - let idx = build_index( - root, - &[ - ("Home", "[[Target#ghost-anchor]]\n"), - ("Target", "= Target =\n"), - ], - ); - let src = "[[Target#ghost-anchor]]\n"; - let ast = parse(src); - let diags = diagnostics::link_health( - &ast, - src, - Some(&home_uri(root)), - &idx, - "Home", - LinkSeverity::Warning, - true, - ); - assert_eq!(diags.len(), 1); - assert!(diags[0].message.to_lowercase().contains("anchor")); -} - -#[test] -fn anchor_only_link_resolves_against_current_page() { - let root = "/tmp/lh5"; - let idx = build_index(root, &[("Home", "= Home =\n== Intro ==\n[[#intro]]\n")]); - let src = "= Home =\n== Intro ==\n[[#intro]]\n"; - let ast = parse(src); - let diags = diagnostics::link_health( - &ast, - src, - Some(&home_uri(root)), - &idx, - "Home", - LinkSeverity::Warning, - true, - ); - assert!(diags.is_empty(), "got: {:?}", diags); -} - -#[test] -fn anchor_only_link_to_missing_anchor_warns() { - let root = "/tmp/lh6"; - let idx = build_index(root, &[("Home", "[[#nowhere]]\n")]); - let src = "[[#nowhere]]\n"; - let ast = parse(src); - let diags = diagnostics::link_health( - &ast, - src, - Some(&home_uri(root)), - &idx, - "Home", - LinkSeverity::Warning, - true, - ); - assert_eq!(diags.len(), 1); - assert!(diags[0].message.contains("nowhere")); -} - -#[test] -fn link_severity_off_emits_nothing() { - let root = "/tmp/lh7"; - let idx = build_index(root, &[("Home", "[[GhostPage]]\n")]); - let src = "[[GhostPage]]\n"; - let ast = parse(src); - let diags = diagnostics::link_health( - &ast, - src, - Some(&home_uri(root)), - &idx, - "Home", - LinkSeverity::Off, - true, - ); - assert!(diags.is_empty()); -} - -#[test] -fn raw_url_never_emits_diagnostic() { - let root = "/tmp/lh8"; - let idx = build_index(root, &[("Home", "https://example.com\n")]); - let src = "https://example.com\n"; - let ast = parse(src); - let diags = diagnostics::link_health( - &ast, - src, - Some(&home_uri(root)), - &idx, - "Home", - LinkSeverity::Error, - true, - ); - assert!(diags.is_empty()); -} - -#[test] -fn tag_as_anchor_resolves() { - let root = "/tmp/lh9"; - let idx = build_index( - root, - &[ - ("Home", "[[Notes#release]]\n"), - ("Notes", "= Notes =\n:release:\n"), - ], - ); - let src = "[[Notes#release]]\n"; - let ast = parse(src); - let diags = diagnostics::link_health( - &ast, - src, - Some(&home_uri(root)), - &idx, - "Home", - LinkSeverity::Warning, - true, - ); - assert!(diags.is_empty(), "got: {:?}", diags); -} - -// ===== BrokenLinkKind classification ===== - -#[test] -fn classify_missing_page() { - let kind = BrokenLinkKind::MissingPage("X".into()); - assert_eq!(kind.tag(), "missing-page"); - assert!(kind.message().contains("X")); -} - -#[test] -fn classify_missing_anchor() { - let kind = BrokenLinkKind::MissingAnchor { - page: "P".into(), - anchor: "a".into(), - }; - assert_eq!(kind.tag(), "missing-anchor"); - assert!(kind.message().contains("a")); - assert!(kind.message().contains("P")); -} - -#[test] -fn classify_missing_file() { - let kind = BrokenLinkKind::MissingFile(PathBuf::from("/tmp/nope.txt")); - assert_eq!(kind.tag(), "missing-file"); - assert!(kind.message().contains("nope.txt")); -} - -// ===== heading_text ===== - -#[test] -fn heading_text_strips_formatting() { - let src = "= *Bold* heading =\n"; - let ast = parse(src); - let h = match &ast.children[0] { - nuwiki_core::ast::BlockNode::Heading(h) => h, - _ => panic!("expected heading"), - }; - let t = diagnostics::heading_text(&h.children); - assert!(t.contains("Bold")); - assert!(t.contains("heading")); -} - -// ===== TOC text generation ===== - -#[test] -fn build_toc_text_nests_by_level() { - let items = vec![ - (1u8, "Top".into(), "top".into()), - (2u8, "Sub".into(), "sub".into()), - (1u8, "Other".into(), "other".into()), - ]; - let out = ops::build_toc_text(&items, "Contents", 1, 0, 0); - assert!(out.starts_with("= Contents =\n")); - let lines: Vec<&str> = out.lines().collect(); - assert_eq!(lines[0], "= Contents ="); - assert_eq!(lines[1], "- [[#top|Top]]"); - assert_eq!(lines[2], " - [[#sub|Sub]]"); - assert_eq!(lines[3], "- [[#other|Other]]"); -} - -#[test] -fn build_toc_text_with_no_headings_is_just_the_heading() { - let out = ops::build_toc_text(&[], "Contents", 1, 0, 0); - assert_eq!(out, "= Contents =\n"); -} - -#[test] -fn toc_link_format_1_drops_the_description() { - // vimwiki toc_link_format: 1 = `[[#anchor]]` (no `|title`); 0 = with title. - let items = vec![(1u8, "Top".into(), "top".into())]; - let f0 = ops::build_toc_text(&items, "Contents", 1, 0, 0); - assert!(f0.contains("- [[#top|Top]]"), "format 0: {f0}"); - let f1 = ops::build_toc_text(&items, "Contents", 1, 0, 1); - assert!(f1.contains("- [[#top]]"), "format 1: {f1}"); - assert!(!f1.contains("|Top"), "format 1 has no description: {f1}"); -} - -#[test] -fn generated_links_caption_emits_first_heading() { - use std::collections::BTreeMap; - let pages = vec!["About".to_string(), "Notes".to_string()]; - let mut caps = BTreeMap::new(); - caps.insert("About".to_string(), "About Us".to_string()); - // Notes has no caption → falls back to bare [[Notes]]. - let out = ops::build_links_text(&pages, "Generated Links", 1, None, 0, Some(&caps)); - assert!(out.contains("- [[About|About Us]]"), "captioned: {out}"); - assert!(out.contains("- [[Notes]]"), "fallback: {out}"); - // Without the caption map, both are bare. - let bare = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None); - assert!( - bare.contains("- [[About]]") && !bare.contains("About Us"), - "bare: {bare}" - ); -} - -#[test] -fn links_rebuild_edit_only_acts_when_section_present() { - let pages = vec!["Home".to_string(), "About".to_string()]; - let uri = Url::from_file_path("/w/Home.wiki").unwrap(); - let without = "= Home =\nbody\n"; - assert!( - ops::links_rebuild_edit( - without, - &parse(without), - &uri, - "Home", - &pages, - true, - "Generated Links", - 1, - 0, - None - ) - .is_none(), - "must not insert a links section into a page that lacks one" - ); - let with = "= Generated Links =\n- [[old]]\n"; - assert!(ops::links_rebuild_edit( - with, - &parse(with), - &uri, - "Home", - &pages, - true, - "Generated Links", - 1, - 0, - None - ) - .is_some()); -} - -#[test] -fn find_section_range_stops_at_same_level_sibling() { - // A non-level-1 generated section (e.g. a level-2 tags index) must not - // swallow the following same-level section when regenerated. - let src = "== Foo ==\n- a\n== Bar ==\n- b\n"; - let ast = parse(src); - let (start, end) = ops::find_section_range(&ast, "Foo").expect("section found"); - let bar_off = src.find("== Bar ==").unwrap(); - assert_eq!(start.offset, 0); - assert!( - end.offset <= bar_off, - "section absorbed the sibling: end={} bar={}", - end.offset, - bar_off - ); -} - -#[test] -fn captions_honour_custom_header_and_level() { - // toc_header="Table of Contents", level 2 → `== Table of Contents ==`. - let out = ops::build_toc_text(&[], "Table of Contents", 2, 0, 0); - assert_eq!(out, "== Table of Contents ==\n"); - // links_header at level 3. - let pages = vec!["A".to_string(), "B".to_string()]; - let links = ops::build_links_text(&pages, "All Pages", 3, None, 0, None); - assert!(links.starts_with("=== All Pages ===\n")); - // level clamps to 1..=6. - assert!(ops::build_toc_text(&[], "X", 9, 0, 0).starts_with("====== X ======")); - assert!(ops::build_toc_text(&[], "X", 0, 0, 0).starts_with("= X =")); -} - -#[test] -fn list_margin_indents_generated_bullets_not_headings() { - // vimwiki `list_margin` = leading spaces before each generated bullet. - // The heading stays at column 0; bullets get `margin` spaces (TOC keeps - // its per-depth nesting *after* the base margin). - let pages = vec!["A".to_string(), "B".to_string()]; - let links = ops::build_links_text(&pages, "Generated Links", 1, None, 2, None); - assert!(links.contains("\n - [[A]]\n"), "2-space margin: {links:?}"); - assert!( - links.starts_with("= Generated Links =\n"), - "heading unindented: {links:?}" - ); - - let toc = ops::build_toc_text( - &[ - (1, "Top".into(), "top".into()), - (2, "Sub".into(), "sub".into()), - ], - "Contents", - 1, - 2, - 0, - ); - // Top bullet: 2-space margin; Sub bullet: margin + one nesting level. - assert!(toc.contains("\n - [[#top|Top]]\n"), "top margin: {toc:?}"); - assert!(toc.contains("\n - [[#sub|Sub]]\n"), "nested: {toc:?}"); -} - -// ===== Links text generation ===== - -#[test] -fn build_links_text_excludes_current_page() { - let pages = vec!["A".to_string(), "Home".to_string(), "B".to_string()]; - let out = ops::build_links_text(&pages, "Generated Links", 1, Some("Home"), 0, None); - let lines: Vec<&str> = out.lines().collect(); - assert_eq!(lines[0], "= Generated Links ="); - assert!(lines.contains(&"- [[A]]")); - assert!(lines.contains(&"- [[B]]")); - assert!(!lines.iter().any(|l| l.contains("Home"))); -} - -#[test] -fn build_links_text_no_excludes_includes_all() { - let pages = vec!["A".to_string(), "B".to_string()]; - let out = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None); - assert!(out.contains("[[A]]")); - assert!(out.contains("[[B]]")); -} - -// ===== find_section_range ===== - -#[test] -fn find_section_range_matches_case_insensitive() { - let src = "= contents =\n- [[A]]\n- [[B]]\n"; - let ast = parse(src); - let (_, _) = ops::find_section_range(&ast, "Contents").expect("found"); -} - -#[test] -fn find_section_range_misses_when_absent() { - let src = "= Welcome =\n"; - let ast = parse(src); - assert!(ops::find_section_range(&ast, "Contents").is_none()); -} - -#[test] -fn find_section_range_matches_any_heading_level() { - // Section replacement accepts any heading level so - // VimwikiGenerateTagLinks stays idempotent when tag sections live - // under non-h1 headings (e.g. `== Tag: foo ==`). - let src = "== Contents ==\n- [[A]]\n"; - let ast = parse(src); - assert!(ops::find_section_range(&ast, "Contents").is_some()); -} - -// ===== toc_edit ===== - -#[test] -fn toc_edit_inserts_at_top_when_no_existing_toc() { - let src = "= One =\n== Two ==\n"; - let ast = parse(src); - let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - let edit = - ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).expect("got an edit"); - let changes = edit.changes.expect("changes map"); - let edits = &changes[&uri]; - assert_eq!(edits.len(), 1); - let te = &edits[0]; - // Insertion at position 0,0 → zero-width range. - assert_eq!(te.range.start, te.range.end); - assert!(te.new_text.starts_with("= Contents =\n")); - // Anchor is the raw heading text (vimwiki scheme), matching the heading id - // the HTML renderer emits on export. - assert!(te.new_text.contains("[[#One|One]]")); -} - -#[test] -fn toc_edit_inserts_at_cursor_line() { - // A fresh TOC goes at the cursor line (0-based) the client sends. - let src = "= One =\nbody text\n== Two ==\nmore\n"; - let ast = parse(src); - let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - // Cursor on line 1 ("body text"). - let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, Some(1)).expect("edit"); - let te = &edit.changes.unwrap()[&uri][0]; - // Zero-width insert at line 1, column 0. - assert_eq!(te.range.start, te.range.end); - assert_eq!(te.range.start.line, 1); - assert_eq!(te.range.start.character, 0); - // A leading blank separates it from the preceding text. - assert!( - te.new_text.starts_with("\n= Contents ="), - "got: {:?}", - te.new_text - ); -} - -#[test] -fn toc_edit_cursor_line_clamped_and_existing_replaced_in_place() { - // An existing TOC is replaced in place regardless of the cursor line. - let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n"; - let ast = parse(src); - let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, Some(99)).expect("edit"); - let te = &edit.changes.unwrap()[&uri][0]; - // Replacement starts at line 0 (the existing section), not the cursor. - assert_eq!(te.range.start.line, 0); - assert!(te.new_text.contains("[[#Real|Real]]")); -} - -#[test] -fn toc_edit_replaces_existing_toc() { - let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n"; - let ast = parse(src); - let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - let edit = - ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).expect("got an edit"); - let changes = edit.changes.expect("changes map"); - let edits = &changes[&uri]; - let te = &edits[0]; - assert!(te.new_text.contains("[[#Real|Real]]")); - assert!(!te.new_text.contains("Stale")); - // The replacement range must start at line 0. - assert_eq!(te.range.start.line, 0); -} - -#[test] -fn toc_edit_returns_none_for_empty_doc() { - let src = ""; - let ast = parse(src); - let uri = Url::parse("file:///tmp/empty.wiki").unwrap(); - assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).is_none()); -} - -#[test] -fn toc_rebuild_edit_only_acts_when_toc_already_present() { - let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - - // No existing TOC → rebuild-on-save must not insert one. - let without = "= One =\n== Two ==\n"; - let ast = parse(without); - assert!( - ops::toc_rebuild_edit(without, &ast, &uri, true, "Contents", 1, 0, 0).is_none(), - "auto_toc should not insert a TOC where none existed" - ); - - // Existing TOC → rebuild refreshes it. - let with = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n"; - let ast = parse(with); - let edit = - ops::toc_rebuild_edit(with, &ast, &uri, true, "Contents", 1, 0, 0).expect("rebuild edit"); - let te = &edit.changes.unwrap()[&uri][0]; - assert!(te.new_text.contains("[[#Real|Real]]")); - assert!(!te.new_text.contains("Stale")); -} - -// ===== links_edit ===== - -#[test] -fn links_edit_inserts_when_section_absent() { - let src = "Hello\n"; - let ast = parse(src); - let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - let pages = vec!["A".into(), "B".into(), "Home".into()]; - let edit = ops::links_edit( - src, - &ast, - &uri, - "Home", - &pages, - true, - "Generated Links", - 1, - 0, - None, - None, - ) - .expect("edit"); - let te = &edit.changes.unwrap()[&uri][0]; - assert!(te.new_text.contains("[[A]]")); - assert!(te.new_text.contains("[[B]]")); - assert!(!te.new_text.contains("[[Home]]")); -} - -#[test] -fn links_edit_inserts_at_cursor_line() { - // A fresh Generated Links section goes at the cursor line, not the EOF. - let src = "= Home =\nbody\nmore\n"; - let ast = parse(src); - let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - let pages = vec!["A".into()]; - let edit = ops::links_edit( - src, - &ast, - &uri, - "Home", - &pages, - true, - "Generated Links", - 1, - 0, - None, - Some(1), - ) - .expect("edit"); - let te = &edit.changes.unwrap()[&uri][0]; - assert_eq!(te.range.start.line, 1); - assert_eq!(te.range.start.character, 0); - assert!( - te.new_text.starts_with("\n= Generated Links ="), - "got: {:?}", - te.new_text - ); -} - -#[test] -fn links_edit_replaces_when_section_present() { - let src = "= Generated Links =\n- [[Stale]]\n\n= Real =\n"; - let ast = parse(src); - let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - let pages = vec!["Fresh".into()]; - let edit = ops::links_edit( - src, - &ast, - &uri, - "Home", - &pages, - true, - "Generated Links", - 1, - 0, - None, - None, - ) - .expect("edit"); - let te = &edit.changes.unwrap()[&uri][0]; - assert!(te.new_text.contains("[[Fresh]]")); - assert!(!te.new_text.contains("Stale")); - assert_eq!(te.range.start.line, 0); -} - -#[test] -fn links_edit_returns_none_for_empty_page_list() { - let src = "Hi\n"; - let ast = parse(src); - let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - assert!(ops::links_edit( - src, - &ast, - &uri, - "Home", - &[], - true, - "Generated Links", - 1, - 0, - None, - None - ) - .is_none()); -} - -// ===== find_orphans ===== - -#[test] -fn find_orphans_lists_pages_with_no_backlinks() { - let root = "/tmp/orph"; - let idx = build_index( - root, - &[ - ("Home", "[[A]]\n"), - ("A", "= A =\n"), - ("B", "= B =\n"), // not linked from anywhere - ], - ); - let orphans = ops::find_orphans(&idx); - let names: Vec<&str> = orphans.iter().map(|o| o.name.as_str()).collect(); - // Home + B have no backlinks. A is linked from Home. - assert!(names.contains(&"Home")); - assert!(names.contains(&"B")); - assert!(!names.contains(&"A")); -} - -// ===== collect_wiki_links (AST walker) ===== - -#[test] -fn collect_wiki_links_finds_nested() { - let src = "= [[A]] =\n*[[B]]* and [[C|desc]]\n- [[D]]\n"; - let ast = parse(src); - let links = nuwiki_lsp::diagnostics::collect_wiki_links(&ast); - let paths: Vec<&str> = links - .iter() - .filter_map(|l| l.target.path.as_deref()) - .collect(); - for expected in ["A", "B", "C", "D"] { - assert!(paths.contains(&expected), "missing {expected}"); - } -} - -// ===== Source-relative wiki links (vimwiki default) ===== - -#[test] -fn wiki_link_resolves_source_relative_first() { - let root = "/tmp/srcrel1"; - let idx = build_index( - root, - &[ - ("tips/index", "[[llm-wiki-pattern|LLM]]\n"), - ("tips/llm-wiki-pattern", "= LLM =\n"), - ], - ); - let src = "[[llm-wiki-pattern|LLM]]\n"; - let ast = parse(src); - let diags = diagnostics::link_health( - &ast, - src, - Some(&Url::from_file_path(format!("{root}/tips/index.wiki")).unwrap()), - &idx, - "tips/index", - LinkSeverity::Warning, - true, - ); - assert!( - diags.is_empty(), - "expected no diagnostics, got: {:?}", - diags - ); -} - -#[test] -fn wiki_link_falls_back_to_root_relative() { - // A source-relative miss should fall through to root-relative — this - // keeps `[[posts/foo]]` from `index.wiki` working even though there's - // no `posts/posts/foo`. - let root = "/tmp/srcrel2"; - let idx = build_index( - root, - &[("index", "[[posts/foo|Foo]]\n"), ("posts/foo", "= Foo =\n")], - ); - let src = "[[posts/foo|Foo]]\n"; - let ast = parse(src); - let diags = diagnostics::link_health( - &ast, - src, - Some(&home_uri(root)), - &idx, - "index", - LinkSeverity::Warning, - true, - ); - assert!(diags.is_empty(), "got: {:?}", diags); -} - -#[test] -fn wiki_link_absolute_skips_source_relative() { - // `[[/Page]]` is explicitly root-relative even from a subdir. - let root = "/tmp/srcrel3"; - let idx = build_index( - root, - &[ - ("tips/index", "[[/RootPage]]\n"), - ("RootPage", "= Root =\n"), - // Intentionally also create tips/RootPage so a source-relative - // resolution would (wrongly) prefer it — the absolute form must - // ignore source-relative. - ("tips/RootPage", "= Tips Root =\n"), - ], - ); - let target = idx - .resolve_wiki_path("RootPage", "tips/index", true) - .expect("absolute link should resolve"); - assert!( - target.as_str().ends_with("RootPage.wiki"), - "expected root RootPage, got {target}", - ); - assert!( - !target.as_str().contains("/tips/RootPage"), - "absolute link should skip tips/RootPage, got {target}", - ); -} - -#[test] -fn wiki_link_parent_dir_collapses() { - // `[[../Other]]` from `posts/foo` resolves to `Other` at the wiki root. - let root = "/tmp/srcrel4"; - let idx = build_index( - root, - &[ - ("posts/foo", "[[../Other|Other]]\n"), - ("Other", "= Other =\n"), - ], - ); - let src = "[[../Other|Other]]\n"; - let ast = parse(src); - let diags = diagnostics::link_health( - &ast, - src, - Some(&Url::from_file_path(format!("{root}/posts/foo.wiki")).unwrap()), - &idx, - "posts/foo", - LinkSeverity::Warning, - true, - ); - assert!(diags.is_empty(), "got: {:?}", diags); -} - -// ===== Anchor matching against raw heading text ===== - -#[test] -fn anchor_matches_raw_heading_text() { - // Users write `[[Page#Some Heading]]` (raw text), the index keys by - // slugify form. Slugifying the request before comparison makes both - // shapes work. - let root = "/tmp/anchor1"; - let idx = build_index( - root, - &[ - ("Home", "[[Target#Some Heading]]\n"), - ("Target", "= Target =\n== Some Heading ==\n"), - ], - ); - let src = "[[Target#Some Heading]]\n"; - let ast = parse(src); - let diags = diagnostics::link_health( - &ast, - src, - Some(&home_uri(root)), - &idx, - "Home", - LinkSeverity::Warning, - true, - ); - assert!(diags.is_empty(), "got: {:?}", diags); -} - -#[test] -fn anchor_matches_unicode_heading() { - // Real-world content: heading with em dash and accented chars. - let root = "/tmp/anchor2"; - let idx = build_index( - root, - &[ - ("Home", "[[Target#Homelab — VLANs]]\n"), - ("Target", "= Target =\n== Homelab — VLANs ==\n"), - ], - ); - let src = "[[Target#Homelab — VLANs]]\n"; - let ast = parse(src); - let diags = diagnostics::link_health( - &ast, - src, - Some(&home_uri(root)), - &idx, - "Home", - LinkSeverity::Warning, - true, - ); - assert!(diags.is_empty(), "got: {:?}", diags); -} - -// ===== COMMANDS completeness ===== - -#[test] -fn commands_list_includes_link_health_entries() { - let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); - for name in [ - "nuwiki.toc.generate", - "nuwiki.links.generate", - "nuwiki.workspace.checkLinks", - "nuwiki.workspace.findOrphans", - ] { - assert!(names.contains(&name), "missing: {name}"); - } -} diff --git a/crates/nuwiki-lsp/tests/multi_wiki.rs b/crates/nuwiki-lsp/tests/multi_wiki.rs deleted file mode 100644 index 51f3a6a..0000000 --- a/crates/nuwiki-lsp/tests/multi_wiki.rs +++ /dev/null @@ -1,220 +0,0 @@ -//! Multi-wiki — interwiki resolution + picker commands. -//! -//! The cross-wiki resolver methods on `Backend` are exercised indirectly -//! through `tower_lsp::LspService`'s test harness via a synthetic -//! `Backend`. To keep this test suite -//! lightweight we drive the *pure* resolution primitives — the parser -//! producing `LinkKind::Interwiki` targets, then the `WorkspaceIndex` -//! lookup-by-name — and verify the multi-wiki config shape end-to-end. - -use std::path::PathBuf; - -use nuwiki_core::ast::{InlineNode, LinkKind}; -use nuwiki_core::syntax::vimwiki::VimwikiSyntax; -use nuwiki_core::syntax::SyntaxPlugin; -use nuwiki_lsp::config::config_from_json; -use nuwiki_lsp::index::WorkspaceIndex; -use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki, WikiId}; -use tower_lsp::lsp_types::Url; - -fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { - VimwikiSyntax::new().parse(src) -} - -fn first_link(src: &str) -> nuwiki_core::ast::WikiLinkNode { - let doc = parse(src); - for block in &doc.children { - if let nuwiki_core::ast::BlockNode::Paragraph(p) = block { - for inline in &p.children { - if let InlineNode::WikiLink(w) = inline { - return w.clone(); - } - } - } - } - panic!("no wikilink in: {src}"); -} - -// ===== Parser-level: `wiki:` / `wn.:` produce Interwiki ===== - -#[test] -fn numbered_interwiki_link_parses_to_wiki_index() { - let w = first_link("[[wiki1:OtherPage]]\n"); - assert_eq!(w.target.kind, LinkKind::Interwiki); - assert_eq!(w.target.wiki_index, Some(1)); - assert_eq!(w.target.path.as_deref(), Some("OtherPage")); - assert!(w.target.wiki_name.is_none()); -} - -#[test] -fn named_interwiki_link_parses_to_wiki_name() { - let w = first_link("[[wn.work:Project]]\n"); - assert_eq!(w.target.kind, LinkKind::Interwiki); - assert_eq!(w.target.wiki_name.as_deref(), Some("work")); - assert_eq!(w.target.path.as_deref(), Some("Project")); - assert!(w.target.wiki_index.is_none()); -} - -#[test] -fn plain_wikilink_is_not_interwiki() { - let w = first_link("[[Home]]\n"); - assert_eq!(w.target.kind, LinkKind::Wiki); - assert!(w.target.wiki_index.is_none()); - assert!(w.target.wiki_name.is_none()); -} - -// ===== Config: `wikis = [...]` shape ===== - -#[test] -fn multi_wiki_config_loads_two_entries() { - let cfg = config_from_json(serde_json::json!({ - "wikis": [ - { "root": "/tmp/personal", "name": "personal" }, - { "root": "/tmp/work", "name": "work" }, - ], - })); - assert_eq!(cfg.wikis.len(), 2); - assert_eq!(cfg.wikis[0].name, "personal"); - assert_eq!(cfg.wikis[1].name, "work"); -} - -#[test] -fn v1_0_single_root_still_works_alongside_multi_wiki_shape() { - // Single-wiki shorthand is the legacy form; ensure it still desugars. - let cfg = config_from_json(serde_json::json!({ - "wiki_root": "/tmp/legacy", - })); - assert_eq!(cfg.wikis.len(), 1); - assert_eq!(cfg.wikis[0].root, PathBuf::from("/tmp/legacy")); -} - -// ===== Wiki aggregate + URI routing ===== - -#[test] -fn build_wikis_assigns_sequential_ids_starting_at_zero() { - let cfg = config_from_json(serde_json::json!({ - "wikis": [ - { "root": "/tmp/p" }, - { "root": "/tmp/q" }, - { "root": "/tmp/r" }, - ], - })); - let wikis = build_wikis(&cfg.wikis); - assert_eq!(wikis.len(), 3); - for (i, w) in wikis.iter().enumerate() { - assert_eq!(w.id, WikiId(i as u32)); - } -} - -#[test] -fn resolve_uri_to_wiki_routes_to_the_owning_root() { - let cfg = config_from_json(serde_json::json!({ - "wikis": [ - { "root": "/tmp/personal" }, - { "root": "/tmp/work" }, - ], - })); - let wikis = build_wikis(&cfg.wikis); - let uri = Url::from_file_path("/tmp/work/Project.wiki").unwrap(); - assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1))); -} - -#[test] -fn nested_root_picks_longest_prefix_match() { - let cfg = config_from_json(serde_json::json!({ - "wikis": [ - { "root": "/notes" }, - { "root": "/notes/sub" }, - ], - })); - let wikis = build_wikis(&cfg.wikis); - let uri = Url::from_file_path("/notes/sub/page.wiki").unwrap(); - // The nested wiki wins because its root is a longer prefix. - assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1))); -} - -// ===== Cross-wiki resolution (uses WorkspaceIndex per wiki) ===== - -/// Verify the cross-wiki resolution semantics: -/// `[[wiki1:Page]]` looks up `Page` in the *second* wiki's index, not -/// the source wiki's. The pure-data check here mirrors what -/// `Backend::resolve_target_uri` does. -#[test] -fn interwiki_resolution_uses_target_wiki_index() { - // Build two wikis with overlapping page names so we can tell whose - // index was consulted. - let mut work = WorkspaceIndex::new(Some(PathBuf::from("/tmp/work"))); - work.upsert( - Url::from_file_path("/tmp/work/Project.wiki").unwrap(), - &parse("= Work Project =\n"), - ); - - let mut personal = WorkspaceIndex::new(Some(PathBuf::from("/tmp/personal"))); - personal.upsert( - Url::from_file_path("/tmp/personal/Project.wiki").unwrap(), - &parse("= Personal Project =\n"), - ); - - // Simulate the resolver: source is personal, target is `wiki1:Project` - // which should hit the work index (assumed to be index 1). - let w = first_link("[[wiki1:Project]]\n"); - assert_eq!(w.target.wiki_index, Some(1)); - let resolved = work.pages_by_name.get("Project").cloned(); - assert!(resolved.is_some(), "work wiki should resolve Project"); - assert!( - resolved - .unwrap() - .as_str() - .contains("/tmp/work/Project.wiki"), - "must come from the work wiki, not personal" - ); -} - -#[test] -fn interwiki_resolution_falls_back_when_wiki_missing() { - // Only one wiki registered; `[[wiki2:X]]` references the third - // wiki by index — no match expected. - let cfg = config_from_json(serde_json::json!({ - "wikis": [ { "root": "/tmp/only" } ], - })); - let wikis = build_wikis(&cfg.wikis); - // Index 2 is out of range. - assert!(wikis.get(2).is_none()); -} - -// ===== Wiki commands: COMMANDS list completeness ===== - -#[test] -fn commands_list_includes_wiki_entries() { - let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); - for name in [ - "nuwiki.wiki.listAll", - "nuwiki.wiki.select", - "nuwiki.wiki.openIndex", - "nuwiki.wiki.tabOpenIndex", - "nuwiki.wiki.gotoPage", - ] { - assert!(names.contains(&name), "missing: {name}"); - } -} - -// ===== Multi-wiki link kind matrix ===== - -#[test] -fn link_kind_matrix_for_typical_targets() { - let cases = &[ - ("[[Home]]", LinkKind::Wiki), - ("[[/Page]]", LinkKind::Wiki), // root-relative still Wiki - ("[[//abs/path]]", LinkKind::Local), // filesystem-absolute - ("[[wiki1:X]]", LinkKind::Interwiki), - ("[[wn.work:Y]]", LinkKind::Interwiki), - ("[[file:foo.pdf]]", LinkKind::File), - ("[[local:foo.pdf]]", LinkKind::Local), - ("[[diary:2026-05-11]]", LinkKind::Diary), - ("[[#anchor]]", LinkKind::AnchorOnly), - ]; - for (src, expected) in cases { - let w = first_link(&format!("{src}\n")); - assert_eq!(w.target.kind, *expected, "case: {src}"); - } -} diff --git a/crates/nuwiki-lsp/tests/navigation.rs b/crates/nuwiki-lsp/tests/navigation.rs deleted file mode 100644 index f0e9e8b..0000000 --- a/crates/nuwiki-lsp/tests/navigation.rs +++ /dev/null @@ -1,240 +0,0 @@ -//! Tests for the navigation pieces: WorkspaceIndex queries, -//! page-name derivation, anchor slugify, position lookup, and link -//! resolution. The async LSP handlers are exercised through these -//! helpers; end-to-end JSON-RPC drive lives elsewhere. - -use std::path::PathBuf; - -use nuwiki_core::ast::{InlineNode, LinkKind}; -use nuwiki_core::syntax::vimwiki::VimwikiSyntax; -use nuwiki_core::syntax::SyntaxPlugin; -use nuwiki_lsp::index::{page_name_from_uri, slugify, WorkspaceIndex}; -use nuwiki_lsp::nav::{find_inline_at, lsp_to_byte_pos}; -use tower_lsp::lsp_types::{Position as LspPosition, Url}; - -fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { - VimwikiSyntax::new().parse(src) -} - -// ===== Slugify ===== - -#[test] -fn slugify_basic_title() { - assert_eq!(slugify("Hello World"), "hello-world"); -} - -#[test] -fn slugify_strips_punctuation_and_collapses_dashes() { - assert_eq!(slugify("Hello, World! Again"), "hello-world-again"); -} - -#[test] -fn slugify_unicode_lowercase() { - assert_eq!(slugify("Crème Brûlée"), "crème-brûlée"); -} - -#[test] -fn slugify_no_trailing_dash() { - assert_eq!(slugify("Title — "), "title"); -} - -// ===== Page name from URI ===== - -#[test] -fn page_name_relative_to_root() { - let root = PathBuf::from("/wiki"); - let uri = Url::from_file_path("/wiki/dir/Page.wiki").unwrap(); - assert_eq!(page_name_from_uri(&uri, Some(&root)), "dir/Page"); -} - -#[test] -fn page_name_at_root() { - let root = PathBuf::from("/wiki"); - let uri = Url::from_file_path("/wiki/Index.wiki").unwrap(); - assert_eq!(page_name_from_uri(&uri, Some(&root)), "Index"); -} - -#[test] -fn page_name_without_root_falls_back_to_stem() { - let uri = Url::from_file_path("/tmp/foo.wiki").unwrap(); - assert_eq!(page_name_from_uri(&uri, None), "foo"); -} - -// ===== Workspace index ===== - -#[test] -fn upsert_indexes_headings_and_outgoing_links() { - let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); - let uri = Url::from_file_path("/wiki/Home.wiki").unwrap(); - let ast = parse("= Home =\n[[Other]] and [[#Section]]\n"); - idx.upsert(uri.clone(), &ast); - - let page = idx.page(&uri).expect("page indexed"); - assert_eq!(page.name, "Home"); - assert_eq!(page.headings.len(), 1); - assert_eq!(page.headings[0].title, "Home"); - assert_eq!(page.headings[0].anchor, "home"); - assert_eq!(page.outgoing.len(), 2); - assert_eq!(page.outgoing[0].target_page.as_deref(), Some("Other")); - assert_eq!(page.outgoing[0].kind, LinkKind::Wiki); - assert_eq!(page.outgoing[1].kind, LinkKind::AnchorOnly); -} - -#[test] -fn upsert_registers_page_by_name_and_records_backlinks() { - let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); - let home = Url::from_file_path("/wiki/Home.wiki").unwrap(); - let about = Url::from_file_path("/wiki/About.wiki").unwrap(); - - idx.upsert(home.clone(), &parse("[[About]]\n")); - idx.upsert(about.clone(), &parse("= About =\n")); - - assert_eq!(idx.page_by_name("About").unwrap().uri, about); - - let backlinks = idx.backlinks_for("About"); - assert_eq!(backlinks.len(), 1); - assert_eq!(backlinks[0].source_uri, home); -} - -#[test] -fn upsert_replaces_prior_indexing_for_same_uri() { - let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); - let uri = Url::from_file_path("/wiki/Home.wiki").unwrap(); - idx.upsert(uri.clone(), &parse("[[A]]\n")); - assert_eq!(idx.backlinks_for("A").len(), 1); - - // Re-upsert with different content: old backlinks must drop. - idx.upsert(uri.clone(), &parse("[[B]]\n")); - assert_eq!(idx.backlinks_for("A").len(), 0); - assert_eq!(idx.backlinks_for("B").len(), 1); -} - -#[test] -fn remove_drops_page_and_backlinks() { - let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); - let home = Url::from_file_path("/wiki/Home.wiki").unwrap(); - let about = Url::from_file_path("/wiki/About.wiki").unwrap(); - idx.upsert(home.clone(), &parse("[[About]]\n")); - idx.upsert(about.clone(), &parse("= About =\n")); - - idx.remove(&home); - assert!(idx.page(&home).is_none()); - assert!(idx.backlinks_for("About").is_empty()); - // The target page is untouched. - assert!(idx.page(&about).is_some()); -} - -#[test] -fn resolve_wiki_link_to_uri() { - let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); - let about = Url::from_file_path("/wiki/About.wiki").unwrap(); - idx.upsert(about.clone(), &parse("= About =\n")); - - let target = nuwiki_core::ast::LinkTarget { - kind: LinkKind::Wiki, - path: Some("About".into()), - ..Default::default() - }; - assert_eq!(idx.resolve(&target, "Home"), Some(&about)); -} - -#[test] -fn resolve_anchor_only_link_returns_source_page() { - let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); - let home = Url::from_file_path("/wiki/Home.wiki").unwrap(); - idx.upsert(home.clone(), &parse("= H =\n[[#Section]]\n")); - - let target = nuwiki_core::ast::LinkTarget { - kind: LinkKind::AnchorOnly, - anchor: Some("section".into()), - ..Default::default() - }; - assert_eq!(idx.resolve(&target, "Home"), Some(&home)); -} - -#[test] -fn page_names_lists_indexed_pages_sorted() { - let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); - for name in ["Charlie", "Alpha", "Bravo"] { - let uri = Url::from_file_path(format!("/wiki/{name}.wiki")).unwrap(); - idx.upsert(uri, &parse(&format!("= {name} =\n"))); - } - let names = idx.page_names(); - assert_eq!(names, vec!["Alpha", "Bravo", "Charlie"]); -} - -// ===== Position conversion ===== - -#[test] -fn lsp_to_byte_pos_passthrough_in_utf8() { - let p = LspPosition { - line: 2, - character: 5, - }; - let (line, col) = lsp_to_byte_pos(p, "ignored", true); - assert_eq!((line, col), (2, 5)); -} - -#[test] -fn lsp_to_byte_pos_converts_from_utf16() { - // 'é' is one UTF-16 code unit, two UTF-8 bytes. Char "2" after 'é': - // UTF-16 col 2 → byte col 3. - let text = "héllo\n"; - let p = LspPosition { - line: 0, - character: 2, - }; - let (line, col) = lsp_to_byte_pos(p, text, false); - assert_eq!(line, 0); - assert_eq!(col, 3); -} - -// ===== Position lookup ===== - -#[test] -fn find_inline_at_returns_wikilink_under_cursor() { - let src = "see [[Other]] now\n"; - let doc = parse(src); - // Cursor inside "Other" — byte col 6 falls inside "[[Other]]" (which - // starts at byte 4 and ends at byte 13). - let node = find_inline_at(&doc, 0, 6).expect("wikilink at cursor"); - assert!(matches!(node, InlineNode::WikiLink(_))); -} - -#[test] -fn find_inline_at_returns_wikilink_when_cursor_on_description() { - // Regression: pressing on the *title* part of a piped link used to - // descend into the description's Text node, so goto-definition saw a Text - // (not a WikiLink) and reported "No locations found". - let src = "see [[Other|My Title]] now\n"; - let doc = parse(src); - // Byte col 16 is inside "Title" (the description), past the '|' at 11. - let node = find_inline_at(&doc, 0, 16).expect("wikilink at cursor"); - assert!(matches!(node, InlineNode::WikiLink(_))); -} - -#[test] -fn find_inline_at_returns_none_for_plain_text() { - let src = "just text\n"; - let doc = parse(src); - let node = find_inline_at(&doc, 0, 3); - // "just text" → Text node, which is what we get back from the inner - // walk (not None). Verify it's a Text. - assert!(matches!(node, Some(InlineNode::Text(_)))); -} - -#[test] -fn find_inline_at_descends_into_inline_containers() { - let src = "*bold text*\n"; - let doc = parse(src); - // Inside "bold" — should return the inner Text, not the wrapping Bold. - let node = find_inline_at(&doc, 0, 3).expect("hit"); - assert!(matches!(node, InlineNode::Text(_))); -} - -#[test] -fn find_inline_at_returns_none_past_eol() { - let src = "short\n"; - let doc = parse(src); - assert!(find_inline_at(&doc, 0, 999).is_none()); -} diff --git a/crates/nuwiki-lsp/tests/semantic_tokens.rs b/crates/nuwiki-lsp/tests/semantic_tokens.rs deleted file mode 100644 index faedb5a..0000000 --- a/crates/nuwiki-lsp/tests/semantic_tokens.rs +++ /dev/null @@ -1,312 +0,0 @@ -//! Tests for the semantic-token builder, encoder, range filter, and -//! UTF-8/UTF-16 conversion. - -use nuwiki_core::syntax::vimwiki::VimwikiSyntax; -use nuwiki_core::syntax::SyntaxPlugin; -use nuwiki_lsp::semantic_tokens::{ - build_data, collect_tokens, encode, legend, LineIndex, RawToken, MOD_CENTERED, MOD_LEVEL1, - MOD_LEVEL2, TOKEN_BOLD, TOKEN_CODE, TOKEN_CODE_BLOCK, TOKEN_COMMENT, TOKEN_DEFINITION_TERM, - TOKEN_HEADING, TOKEN_ITALIC, TOKEN_KEYWORD, TOKEN_MATH_BLOCK, TOKEN_TRANSCLUSION, - TOKEN_TYPE_NAMES, TOKEN_URL, TOKEN_WIKILINK, -}; -use tower_lsp::lsp_types::{Position, Range}; - -fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { - VimwikiSyntax::new().parse(src) -} - -fn first_with_kind(tokens: &[RawToken], kind: u32) -> Option<&RawToken> { - tokens.iter().find(|t| t.kind == kind) -} - -// ===== Legend ===== - -#[test] -fn legend_lists_all_custom_token_types() { - let l = legend(); - assert_eq!(l.token_types.len(), TOKEN_TYPE_NAMES.len()); - let names: Vec = l - .token_types - .iter() - .map(|t| t.as_str().to_owned()) - .collect(); - assert!(names.contains(&"vimwikiHeading".to_string())); - assert!(names.contains(&"vimwikiWikilink".to_string())); - assert!(names.contains(&"vimwikiTransclusion".to_string())); -} - -// ===== Per-construct emission ===== - -#[test] -fn heading_emits_one_token_with_level_modifier() { - let doc = parse("== Hi ==\n"); - let toks = collect_tokens(&doc, "== Hi ==\n"); - let h = first_with_kind(&toks, TOKEN_HEADING).expect("heading token"); - assert_eq!(h.mods & MOD_LEVEL2, MOD_LEVEL2); - assert_eq!(h.mods & MOD_CENTERED, 0); -} - -#[test] -fn centered_heading_carries_centered_modifier() { - let src = " = Title =\n"; - let doc = parse(src); - let toks = collect_tokens(&doc, src); - let h = first_with_kind(&toks, TOKEN_HEADING).expect("heading token"); - assert_eq!(h.mods & MOD_LEVEL1, MOD_LEVEL1); - assert_eq!(h.mods & MOD_CENTERED, MOD_CENTERED); -} - -#[test] -fn heading_does_not_emit_inner_inline_tokens() { - // Headings render as a single styled span, so bold inside is shadowed. - let src = "= Hi *bold* =\n"; - let doc = parse(src); - let toks = collect_tokens(&doc, src); - assert!(toks.iter().any(|t| t.kind == TOKEN_HEADING)); - assert!( - !toks.iter().any(|t| t.kind == TOKEN_BOLD), - "should not have emitted Bold inside a Heading" - ); -} - -#[test] -fn paragraph_emits_inline_tokens_only() { - let src = "see *bold* and `code`\n"; - let doc = parse(src); - let toks = collect_tokens(&doc, src); - assert!(toks.iter().any(|t| t.kind == TOKEN_BOLD)); - assert!(toks.iter().any(|t| t.kind == TOKEN_CODE)); - assert!(!toks.iter().any(|t| t.kind == TOKEN_HEADING)); -} - -#[test] -fn italic_token_for_underscored_run() { - let src = "_emphasis_\n"; - let doc = parse(src); - let toks = collect_tokens(&doc, src); - assert!(toks.iter().any(|t| t.kind == TOKEN_ITALIC)); -} - -#[test] -fn keyword_token_for_each_keyword() { - let src = "TODO write tests, FIXME later\n"; - let doc = parse(src); - let toks = collect_tokens(&doc, src); - let kw_count = toks.iter().filter(|t| t.kind == TOKEN_KEYWORD).count(); - assert_eq!(kw_count, 2); -} - -#[test] -fn wikilink_and_external_link_get_different_types() { - let src = "[[Page]] and [[https://example.com|docs]]\n"; - let doc = parse(src); - let toks = collect_tokens(&doc, src); - assert!(toks.iter().any(|t| t.kind == TOKEN_WIKILINK)); - assert!( - toks.iter() - .any(|t| t.kind == nuwiki_lsp::semantic_tokens::TOKEN_EXTERNAL_LINK), - "URL inside [[...]] should be vimwikiExternalLink, got {toks:?}" - ); -} - -#[test] -fn raw_url_emits_url_token() { - let src = "see https://example.com here\n"; - let doc = parse(src); - let toks = collect_tokens(&doc, src); - assert!(toks.iter().any(|t| t.kind == TOKEN_URL)); -} - -#[test] -fn transclusion_emits_transclusion_token() { - let src = "{{img.png|alt}}\n"; - let doc = parse(src); - let toks = collect_tokens(&doc, src); - assert!(toks.iter().any(|t| t.kind == TOKEN_TRANSCLUSION)); -} - -#[test] -fn comment_token_for_single_line_comment() { - let src = "%% hidden\nstuff\n"; - let doc = parse(src); - let toks = collect_tokens(&doc, src); - assert!(toks.iter().any(|t| t.kind == TOKEN_COMMENT)); -} - -#[test] -fn definition_term_token() { - let src = "Term:: Definition\n"; - let doc = parse(src); - let toks = collect_tokens(&doc, src); - assert!(toks.iter().any(|t| t.kind == TOKEN_DEFINITION_TERM)); -} - -// ===== Multi-line fences ===== - -#[test] -fn preformatted_block_splits_into_one_token_per_line() { - let src = "{{{rust\nlet x = 1;\nlet y = 2;\n}}}\n"; - let doc = parse(src); - let toks = collect_tokens(&doc, src); - let code_tokens: Vec<&RawToken> = toks.iter().filter(|t| t.kind == TOKEN_CODE_BLOCK).collect(); - // Spans line 0 (open fence) through line 3 (close fence) = 4 lines, but - // empty trailing slices are dropped, so we expect 4 line tokens. - assert!( - code_tokens.len() >= 3, - "expected multi-line split, got {code_tokens:?}" - ); - // No token should cross a line boundary. - for t in &code_tokens { - assert!(t.byte_len > 0); - } -} - -#[test] -fn math_block_splits_into_one_token_per_line() { - let src = "{{$\nx=1\ny=2\n}}$\n"; - let doc = parse(src); - let toks = collect_tokens(&doc, src); - let math_tokens: Vec<&RawToken> = toks.iter().filter(|t| t.kind == TOKEN_MATH_BLOCK).collect(); - assert!(math_tokens.len() >= 3); -} - -// ===== Sorting ===== - -#[test] -fn tokens_are_sorted_by_line_then_column() { - let src = "= H =\n*bold* and _italic_\n"; - let doc = parse(src); - let toks = collect_tokens(&doc, src); - for w in toks.windows(2) { - let a = (w[0].line, w[0].byte_col); - let b = (w[1].line, w[1].byte_col); - assert!(a <= b, "tokens not sorted: {a:?} then {b:?}"); - } -} - -// ===== Encoding ===== - -#[test] -fn encode_produces_correct_delta_quintuples() { - let src = "= H =\n*bold*\n"; - let raws = vec![ - RawToken { - line: 0, - byte_col: 0, - byte_len: 5, - kind: TOKEN_HEADING, - mods: MOD_LEVEL1, - }, - RawToken { - line: 1, - byte_col: 0, - byte_len: 6, - kind: TOKEN_BOLD, - mods: 0, - }, - ]; - let idx = LineIndex::new(src); - let data = encode(&raws, src, &idx, true); - assert_eq!(data.len(), 10); - // First token: delta_line=0, delta_col=0, len=5, type=HEADING, mods=LEVEL1 - assert_eq!(&data[0..5], &[0, 0, 5, TOKEN_HEADING, MOD_LEVEL1]); - // Second token: delta_line=1 (new line), delta_col=0 (absolute), len=6, type=BOLD, mods=0 - assert_eq!(&data[5..10], &[1, 0, 6, TOKEN_BOLD, 0]); -} - -#[test] -fn encode_uses_relative_column_within_same_line() { - let src = "abc def ghi\n"; - let raws = vec![ - RawToken { - line: 0, - byte_col: 0, - byte_len: 3, - kind: TOKEN_BOLD, - mods: 0, - }, - RawToken { - line: 0, - byte_col: 4, - byte_len: 3, - kind: TOKEN_ITALIC, - mods: 0, - }, - RawToken { - line: 0, - byte_col: 8, - byte_len: 3, - kind: TOKEN_CODE, - mods: 0, - }, - ]; - let idx = LineIndex::new(src); - let data = encode(&raws, src, &idx, true); - assert_eq!(&data[0..5], &[0, 0, 3, TOKEN_BOLD, 0]); - // Second: delta_col = 4 - 0 = 4 - assert_eq!(&data[5..10], &[0, 4, 3, TOKEN_ITALIC, 0]); - // Third: delta_col = 8 - 4 = 4 - assert_eq!(&data[10..15], &[0, 4, 3, TOKEN_CODE, 0]); -} - -#[test] -fn encode_converts_to_utf16_for_multibyte_chars() { - // "_é_" — italic delimiters around é. Span covers 4 bytes (1 + 2 + 1) - // but 3 UTF-16 code units. - let src = "_é_\n"; - let raws = vec![RawToken { - line: 0, - byte_col: 0, - byte_len: 4, - kind: TOKEN_ITALIC, - mods: 0, - }]; - let idx = LineIndex::new(src); - let data = encode(&raws, src, &idx, false); - // length should be in UTF-16 code units (3), not bytes (4). - assert_eq!(data[2], 3); -} - -// ===== Range filter ===== - -#[test] -fn range_filter_keeps_only_overlapping_tokens() { - let src = "= L1 =\n= L2 =\n= L3 =\n"; - let doc = parse(src); - // Restrict to line 1 only. - let range = Range { - start: Position { - line: 1, - character: 0, - }, - end: Position { - line: 2, - character: 0, - }, - }; - let data = build_data(&doc, src, true, Some(range)); - // Expect exactly 1 heading token (the L2 one). - assert_eq!(data.len(), 5, "got {data:?}"); - // Verify it's on line 1 (delta_line = 1 from prev_line = 0). - assert_eq!(data[0], 1); -} - -#[test] -fn empty_document_produces_empty_token_stream() { - let doc = parse(""); - let data = build_data(&doc, "", true, None); - assert!(data.is_empty()); -} - -// ===== End-to-end through builder ===== - -#[test] -fn build_data_matches_collect_then_encode() { - let src = "= H1 =\n_em_ TODO\n"; - let doc = parse(src); - let direct = build_data(&doc, src, true, None); - let raws = collect_tokens(&doc, src); - let idx = LineIndex::new(src); - let manual = encode(&raws, src, &idx, true); - assert_eq!(direct, manual); -} diff --git a/development/ONBOARDING.md b/development/ONBOARDING.md index 1909afd..140df2a 100644 --- a/development/ONBOARDING.md +++ b/development/ONBOARDING.md @@ -4,41 +4,12 @@ nuwiki is a vimwiki-compatible Vim/Neovim plugin backed by a Rust language server. It provides full vimwiki syntax support while keeping the original file format, keymaps, and command surface intact. The substantive work happens in a Rust LSP daemon — Vim and Neovim are thin client layers that wire up keystrokes and display results. +The Rust LSP server lives in the separate [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository. This plugin repo is the Vim/Neovim client layer and is distributed independently from the server binary. + ## Repository Structure ``` nuwiki/ -├── Cargo.toml # Rust workspace root -├── Cargo.lock -├── crates/ -│ ├── nuwiki-ls/ # Binary crate — thin main.rs, starts stdio LSP server -│ │ ├── Cargo.toml -│ │ └── src/main.rs -│ │ -│ ├── nuwiki-core/ # Library crate — parser, AST, renderer (no editor deps) -│ │ ├── Cargo.toml -│ │ └── src/ -│ │ ├── lib.rs -│ │ ├── syntax/ # Syntax plugins (vimwiki, future markdown) -│ │ │ ├── mod.rs -│ │ │ ├── registry.rs -│ │ │ └── vimwiki/ -│ │ │ ├── mod.rs -│ │ │ ├── lexer.rs -│ │ │ └── parser.rs -│ │ ├── ast/ # Abstract Syntax Tree node definitions -│ │ │ ├── mod.rs -│ │ │ ├── block.rs -│ │ │ ├── inline.rs -│ │ │ └── link.rs -│ │ └── render/ # Renderers (HTML, etc.) -│ │ ├── mod.rs -│ │ └── html.rs -│ │ -│ └── nuwiki-lsp/ # Library crate — LSP protocol bridge -│ ├── Cargo.toml -│ └── src/lib.rs -│ ├── plugin/ # Universal Vim/Neovim entry point │ └── nuwiki.vim │ @@ -47,7 +18,7 @@ nuwiki/ │ ├── init.lua │ ├── config.lua │ ├── lsp.lua -│ └── install.lua # Binary download / build-from-source logic +│ └── install.lua # Binary download logic │ ├── autoload/ # Lazy-loaded VimL (Vim compat layer) │ └── nuwiki/ @@ -92,26 +63,21 @@ nuwiki/ └── release.yaml # Cross-compile + release on v* tag ``` -## Key Crates +## Server Component -1. **nuwiki-core**: Contains the parser, lexer, AST, and renderer. This is the pure-Rust core with no editor dependencies. -2. **nuwiki-lsp**: Bridges the core to the LSP protocol using tower-lsp. Handles LSP requests/responses and manages the document store. -3. **nuwiki-ls**: Binary crate that starts the stdio LSP server. Very thin — just initializes the LSP server and runs it. +The Rust LSP server (`nuwiki-ls`) lives in the separate [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository. Pre-built binaries are downloaded automatically by the plugin's `install()` function. To build from source: + +```bash +git clone https://code.gfran.co/gffranco/nuwiki-rs +cd nuwiki-rs +cargo build --release -p nuwiki-ls +``` ## Build & Installation ### Prerequisites -- Rust toolchain (1.83+ stable) -- Cargo - For editor integration: Vim 9+ or Neovim 0.5+ with an LSP client (vim-lsp recommended for Vim, built-in for Neovim 0.11+) - -### Development Build -```bash -# From the repository root -cargo build --release -p nuwiki-ls # Builds the LSP binary -``` - -The binary will be placed at `target/release/nuwiki-ls`. +- The `nuwiki-ls` binary is downloaded automatically; no Rust toolchain required unless building from source ### Installation via Plugin Managers @@ -143,37 +109,34 @@ call dein#add('gffranco/nuwiki', { ```bash git clone https://code.gfran.co/gffranco/nuwiki ~/.vim/pack/gffranco/start/nuwiki cd ~/.vim/pack/gffranco/start/nuwiki -cargo build --release -p nuwiki-ls -mkdir -p bin && ln -s ../target/release/nuwiki-ls bin/nuwiki-ls +vim -e -s -c "source scripts/download_bin.vim" -c "q" ``` Plain Vim users also need an LSP client — vim-lsp is recommended. ## Development Workflow -### Testing -```bash -# Run all tests (workspace) -cargo test --workspace +### Testing (Editor Layer) -# Run tests for a specific crate -cargo test -p nuwiki-core -cargo test -p nuwiki-lsp -cargo test -p nuwiki-ls +```bash +# Run editor tests (shell + Lua harnesses in development/tests/) +./development/tests/test-keymaps.sh +./development/tests/test-calendar.sh ``` -### Linting & Formatting -```bash -# Check formatting -cargo fmt -- --check +For Rust-level tests, see the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository: -# Run Clippy +```bash +# In the nuwiki-rs repo +cargo test --workspace cargo clippy --workspace -- -D warnings ``` ### Running the LSP Server Manually (for debugging) + +Build from [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) and run: + ```bash -# Build and run the binary cargo run -p nuwiki-ls -- --help ``` @@ -210,44 +173,35 @@ This verifies: ## Important Notes -- The core library (`nuwiki-core`) is completely editor-agnostic and can be tested independently. -- The LSP layer (`nuwiki-lsp`) depends only on the core and tower-lsp. -- The binary crate (`nuwiki-ls`) is intentionally thin — it just initializes and runs the LSP server. +- The Rust LSP server is in the separate [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository. +- This plugin repo contains only the editor client layer (VimL/Lua). - Editor layers (VimL/Lua) contain zero logic — they are pure wiring only. -- All syntax-specific code lives in `nuwiki-core/src/syntax//` making it easy to add new syntaxes (e.g., markdown) in the future. -- The project uses a workspace Cargo.toml with resolver v2 (edition 2021). +- All syntax-specific code lives in the Rust LSP server's `nuwiki-core` crate. ## Getting Started -1. Clone the repository: - ```bash - git clone https://code.gfran.co/gffranco/nuwiki - cd nuwiki - ``` +1. Clone this plugin repository: + ```bash + git clone https://code.gfran.co/gffranco/nuwiki + cd nuwiki + ``` -2. Build the LSP binary: - ```bash - cargo build --release -p nuwiki-ls - ``` +2. Install the plugin in your Neovim/Vim configuration using your preferred plugin manager (see above examples). The `nuwiki-ls` binary will be downloaded automatically. -3. Set up a test wiki directory: - ```bash - mkdir -p ~/test-wiki - echo "# Test Wiki" > ~/test-wiki/index.wiki - ``` +3. Open a .wiki file and verify: + - Syntax highlighting works + - `:VimwikiIndex` opens the index page + - LSP features (go-to-definition, hover, completions) work via `:checkhealth nuwiki` -4. Install the plugin in your Neovim/Vim configuration using your preferred plugin manager (see above examples). - -5. Open a .wiki file and verify: - - Syntax highlighting works - - `:VimwikiIndex` opens the index page - - LSP features (go-to-definition, hover, completions) work via `:checkhealth nuwiki` +To modify the LSP server itself, clone the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository and build the binary from source. ## CI/CD -The project uses Gitea Actions: -- CI (`.gitea/workflows/ci.yaml`): Runs on every push/PR — checks formatting, Clippy, and runs tests. -- Release (`.gitea/workflows/release.yaml`): Triggers on `v*` tag — cross-compiles for Linux targets and creates a Gitea release. +This repo (editor client) uses Gitea Actions: +- CI (`.gitea/workflows/ci.yaml`): Runs editor tests on every push/PR. + +The Rust LSP server lives in [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) which handles: +- Release (`.gitea/workflows/release.yaml`): Triggers on `v*` tag — cross-compiles for Linux targets and creates a Gitea release with downloadable binaries. ## License diff --git a/development/SPEC.md b/development/SPEC.md index f398e0c..8205bbb 100644 --- a/development/SPEC.md +++ b/development/SPEC.md @@ -6,16 +6,12 @@ For day-one setup see [ONBOARDING.md](ONBOARDING.md); for user-facing help see nuwiki is a vimwiki-compatible Vim/Neovim plugin backed by a Rust language server. The editor layers are thin clients; all parsing, navigation, and -editing logic lives in the Rust crates and is reached over LSP. +editing logic lives in the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) +repository and is reached over LSP. | Property | Value | |---|---| | License | Dual MIT / Apache-2.0 | -| MSRV | Rust 1.83 | -| Edition / resolver | 2021 / v2 | -| Repo | `https://code.gfran.co/gffranco/nuwiki` | -| VCS / CI | Gitea + Gitea Actions | -| LSP library | `tower-lsp` (tokio, stdio transport) | | Min editors | Neovim 0.11+, Vim 9.1+ | --- @@ -28,42 +24,21 @@ editing logic lives in the Rust crates and is reached over LSP. Editor client (VimL / Lua) — pure wiring, no logic │ LSP over stdio ▼ -nuwiki-lsp — protocol bridge, executeCommand, document store, config - │ - ▼ -nuwiki-core — lexer → parser → AST → renderer (no editor deps) +nuwiki-ls (nuwiki-rs) — Rust LSP server: protocol bridge, executeCommand, + document store, config, parser, renderer ``` -### Crate dependency rules +> The Rust LSP server is in the separate [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) +> repository. This plugin repo is the Vim/Neovim client layer only. +> +> The architecture table below is for reference — the Rust source lives in nuwiki-rs. -``` -nuwiki-ls → nuwiki-lsp → nuwiki-core -``` - -- `nuwiki-core` never depends on `nuwiki-lsp` / `nuwiki-ls`. -- `nuwiki-lsp` never depends on `nuwiki-ls`. - The VimL and Lua layers contain no logic — every command body issues a `workspace/executeCommand` or a built-in LSP request. ### Repository layout ``` -crates/ - nuwiki-core/ # parser, AST, renderer — editor-independent - src/ - ast/ # block.rs inline.rs link.rs span.rs visit.rs - syntax/ # registry.rs + vimwiki/{lexer,parser}.rs - render/ # html.rs - date.rs # diary period math (no chrono dependency) - nuwiki-lsp/ # LSP backend - src/ - lib.rs # Backend, capabilities, textDocument handlers - commands.rs # executeCommand dispatcher + pure edit ops - config.rs # Config / WikiConfig / HtmlConfig - diagnostics.rs index.rs nav.rs rename.rs semantic_tokens.rs - diary.rs export.rs edits.rs folding.rs wiki.rs - nuwiki-ls/ # thin binary: starts the stdio server - plugin/nuwiki.vim # universal entry point (detects Vim vs Neovim) lua/nuwiki/ # Neovim layer: init, config, lsp, keymaps, commands, # folding, install @@ -72,12 +47,15 @@ ftdetect/ ftplugin/ syntax/ # filetype detection, buffer setup, fallback HL doc/nuwiki.txt # :help scripts/download_bin.vim # plugin-runtime binary download (build hooks) development/ # dev-only tooling + this document (not shipped) -.gitea/workflows/ # ci.yaml, release.yaml ``` --- -## 2. nuwiki-core +## 2. nuwiki-core (nuwiki-rs) + +> This section documents the Rust server's internals — the source lives in +> the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository. Kept +> here as a technical reference for the LSP protocol contract. ### AST @@ -199,9 +177,8 @@ raw and external URLs are never diagnosed. Pre-built binaries are published as Gitea release assets (`nuwiki-ls-{version}-{target}.tar.gz`) and fetched at install time by `lua/nuwiki/install.lua` (Neovim) or `scripts/download_bin.vim` (Vim build -hooks), installed to `{plugin_dir}/bin/nuwiki-ls`. Falls back to -`cargo build --release` when download fails or -`g:nuwiki_build_from_source = 1`. +hooks), installed to `{plugin_dir}/bin/nuwiki-ls`. The binary is downloaded +from the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) releases. `plugin/nuwiki.vim` dispatches: Neovim → `require('nuwiki').setup()`; Vim → `nuwiki#lsp#start()` (prefers `vim-lsp`, falls back to `coc.nvim`). On Neovim @@ -323,16 +300,12 @@ provides a no-LSP fallback): | Job | Command | |---|---| -| fmt | `cargo fmt --all -- --check` | -| clippy | `cargo clippy --workspace --all-targets -- -D warnings` | -| test | `cargo test --workspace --all-targets` | | keymaps | `development/tests/test-keymaps.sh` (Neovim 0.11) + `test-keymaps-vim.sh` (Vim) | +| calendar | `development/tests/test-calendar.sh` (Neovim) + `test-calendar-vim.sh` (Vim) | -`.gitea/workflows/release.yaml` triggers on `v*` tags: cross-compiles the four -Linux targets (gnu/musl × x86_64/aarch64) via `cross`, packages `.tar.gz`, and -creates a Gitea release using `RELEASE_TOKEN`. macOS and Windows binaries are -built manually and attached to the same release. crates.io publishing is not -wired up. +`.gitea/workflows/release.yaml` (in [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs)) +triggers on `v*` tags: cross-compiles the four Linux targets (gnu/musl × x86_64/aarch64) +via `cross`, packages `.tar.gz`, and creates a Gitea release using `RELEASE_TOKEN`. A change is semver-breaking if it alters `nuwiki-core` AST nodes, the `SyntaxPlugin`/`Renderer` traits, the user-config schema, or removes an LSP diff --git a/development/_common.sh b/development/_common.sh index 59332f9..d245073 100644 --- a/development/_common.sh +++ b/development/_common.sh @@ -10,7 +10,7 @@ # Exports: # REPO_ROOT / DEV_DIR / WIKI_DIR — common paths # log — prefixed status line -# ensure_binary — build + symlink nuwiki-ls +# ensure_binary — verify nuwiki-ls exists (download if missing) # write_sample_wiki DIR — lay down the feature-showcase wiki # reset_dirs DIR... — wipe + recreate owned state dirs # seed_wiki — (re)seed WIKI_DIR fresh each run; never @@ -27,20 +27,34 @@ log() { printf '\033[1;34m[nuwiki-dev]\033[0m %s\n' "$*"; } ensure_binary() { local bin="$REPO_ROOT/bin/nuwiki-ls" - local built="$REPO_ROOT/target/release/nuwiki-ls" - if [[ "${NUWIKI_DEV_SKIP_BUILD:-0}" == "1" ]]; then - if [[ ! -x "$bin" ]]; then - log "NUWIKI_DEV_SKIP_BUILD=1 but $bin is missing — building anyway" - else - log "NUWIKI_DEV_SKIP_BUILD=1 — using existing $bin" - return - fi + if [[ -x "$bin" ]]; then + log "binary ready: $bin" + return + fi + if [[ "${NUWIKI_DEV_SKIP_BUILD:-0}" == "1" ]]; then + log "NUWIKI_DEV_SKIP_BUILD=1 but $bin is missing" + return 1 + fi + log "downloading nuwiki-ls…" + vim -e -s -c "source $REPO_ROOT/scripts/download_bin.vim" -c "q" + if [[ -x "$bin" ]]; then + log "binary ready: $bin" + return + fi + log "download failed, falling back to cargo build from nuwiki-rs…" + local rs_repo="$REPO_ROOT/nuwiki-rs" + if [[ ! -f "$rs_repo/Cargo.toml" ]]; then + log "cloning nuwiki-rs …" + git clone --depth 1 https://code.gfran.co/gffranco/nuwiki-rs.git "$rs_repo" + fi + cargo build --release -p nuwiki-ls --manifest-path "$rs_repo/Cargo.toml" + if [[ ! -f "$rs_repo/target/release/nuwiki-ls" ]]; then + log "cargo build failed" + return 1 fi - log "building nuwiki-ls (release)…" - cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" mkdir -p "$REPO_ROOT/bin" - ln -sf "$built" "$bin" - log "binary ready: $bin (built $(date -r "$built" '+%Y-%m-%d %H:%M:%S'))" + ln -sf "$rs_repo/target/release/nuwiki-ls" "$bin" + log "binary ready: $bin" } # write_sample_wiki DIR — lay down a fixture that exercises every vimwiki diff --git a/development/start-nvim.sh b/development/start-nvim.sh index d157708..4d6bfd8 100755 --- a/development/start-nvim.sh +++ b/development/start-nvim.sh @@ -2,25 +2,17 @@ # # start-nvim.sh — launch Neovim with a minimal config that loads nuwiki. # -# Builds the language server (release mode), symlinks it into `bin/` so -# the plugin's default install path resolves, then spawns Neovim with -# `--clean` and a generated `init.lua` that: -# * adds this repo to `runtimepath` -# * configures wiki_root to a scratch directory under -# $XDG_CACHE_HOME/nuwiki-dev/wiki -# * calls `require('nuwiki').setup({...})` +# The plugin's install() downloads a pre-built release of nuwiki-ls for +# your platform from the nuwiki-rs repository. On first launch a missing +# binary is auto-downloaded via the bundled Vim script; set +# NUWIKI_DEV_SKIP_BUILD=1 to skip the download entirely. # # Usage: ./development/start-nvim.sh [extra args...] # ./development/start-nvim.sh # open the scratch wiki's index # ./development/start-nvim.sh path/to/note.wiki # open a specific file # NUWIKI_DEV_WIKI=/tmp/foo ./development/start-nvim.sh # NUWIKI_DEV_NO_SEED=1 ./development/start-nvim.sh # use WIKI_DIR as-is (no scratch seeding) -# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-nvim.sh # reuse the cached binary -# -# The release binary is rebuilt on every launch so source changes -# always reach the running LSP. `cargo build --release` is incremental, -# so this is a fast no-op when nothing changed. Set -# NUWIKI_DEV_SKIP_BUILD=1 to skip the build step entirely. +# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-nvim.sh # skip the download step # # Each launch starts clean: the scratch wiki is reseeded from a pristine # copy and the editor state (cache/data/config) is wiped, so stale edits diff --git a/development/start-vim-coc.sh b/development/start-vim-coc.sh index 1ee4af5..41d0521 100755 --- a/development/start-vim-coc.sh +++ b/development/start-vim-coc.sh @@ -22,13 +22,12 @@ # ./development/start-vim-coc.sh path/to/note.wiki # open a specific file # NUWIKI_DEV_WIKI=/tmp/foo ./development/start-vim-coc.sh # NUWIKI_DEV_NO_SEED=1 ./development/start-vim-coc.sh # use WIKI_DIR as-is (no scratch seeding) -# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim-coc.sh # reuse the cached binary +# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim-coc.sh # skip the download # NUWIKI_DEV_COC_JUMP=edit ./development/start-vim-coc.sh # change coc.preferences.jumpCommand # NUWIKI_DEV_VIMWIKI=1 ./development/start-vim-coc.sh # drive nuwiki from a g:vimwiki_list config (drop-in) # -# The release binary is rebuilt on every launch so source changes always reach -# the running LSP. `cargo build --release` is incremental, so this is a fast -# no-op when nothing changed. Set NUWIKI_DEV_SKIP_BUILD=1 to skip the build. +# The LSP binary is downloaded on every launch if missing, falling back to a +# cached version. Set NUWIKI_DEV_SKIP_BUILD=1 to skip the download. # # Each launch starts clean: the scratch wiki is reseeded from a pristine # copy and the editor state (viminfo/swap/undo, coc data) is wiped, so stale diff --git a/development/start-vim.sh b/development/start-vim.sh index 3ad5aa5..073fa30 100755 --- a/development/start-vim.sh +++ b/development/start-vim.sh @@ -7,18 +7,18 @@ # the dev cache on first run so the LSP integration is exercisable # without polluting your real Vim install. # +# The plugin's install() downloads a pre-built release of nuwiki-ls for +# your platform from the nuwiki-rs repository. On first launch a missing +# binary is auto-downloaded via the bundled Vim script; set +# NUWIKI_DEV_SKIP_BUILD=1 to skip the download entirely. +# # Usage: ./development/start-vim.sh [extra args...] # ./development/start-vim.sh # open the scratch wiki's index # ./development/start-vim.sh path/to/note.wiki # open a specific file # NUWIKI_DEV_WIKI=/tmp/foo ./development/start-vim.sh # NUWIKI_DEV_NO_LSP=1 ./development/start-vim.sh # skip vim-lsp setup # NUWIKI_DEV_NO_SEED=1 ./development/start-vim.sh # use WIKI_DIR as-is (no scratch seeding) -# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim.sh # reuse the cached binary -# -# The release binary is rebuilt on every launch so source changes -# always reach the running LSP. `cargo build --release` is incremental, -# so this is a fast no-op when nothing changed. Set -# NUWIKI_DEV_SKIP_BUILD=1 to skip the build step entirely. +# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim.sh # skip the download step # # Each launch starts clean: the scratch wiki is reseeded from a pristine # copy and the editor state (viminfo/swap/undo) is wiped, so stale edits diff --git a/development/tests/test-calendar.sh b/development/tests/test-calendar.sh index e7fe1b2..e148bac 100755 --- a/development/tests/test-calendar.sh +++ b/development/tests/test-calendar.sh @@ -10,8 +10,8 @@ # * opting out with use_calendar = false. # # A stub calendar-vim on the runtimepath provides :Calendar and seeds the -# default hook names. The LSP binary is built (setup() registers the client); -# the calendar paths themselves don't need the server. +# default hook names. The LSP binary is downloaded (setup() registers the +# client); the calendar paths themselves don't need the server. # # Exit: 0 when both runs are green, 1 otherwise. Skips (0) without nvim. @@ -28,13 +28,34 @@ if ! command -v nvim >/dev/null 2>&1; then exit 0 fi -# setup() registers the LSP client, so build the binary like the other -# Neovim harnesses do (incremental, fast). +# Download the LSP binary from nuwiki-rs releases (falls back to cargo build). BIN="$REPO_ROOT/bin/nuwiki-ls" -log 'building nuwiki-ls (release, incremental)…' -cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" >/dev/null -mkdir -p "$REPO_ROOT/bin" -ln -sf "$REPO_ROOT/target/release/nuwiki-ls" "$BIN" +log 'downloading nuwiki-ls…' +if [[ ! -x "$BIN" ]]; then + vim -e -s -c "source $REPO_ROOT/scripts/download_bin.vim" -c "q" >/dev/null 2>&1 || true +fi + +# If download failed, fall back to cargo build from nuwiki-rs. +if [[ ! -x "$BIN" ]]; then + log "download failed, falling back to cargo build from nuwiki-rs…" + rs_repo="$REPO_ROOT/nuwiki-rs" + if [[ ! -f "$rs_repo/Cargo.toml" ]]; then + log "cloning nuwiki-rs …" + git clone --depth 1 https://code.gfran.co/gffranco/nuwiki-rs.git "$rs_repo" + fi + cargo build --release -p nuwiki-ls --manifest-path "$rs_repo/Cargo.toml" >/dev/null + if [[ ! -f "$rs_repo/target/release/nuwiki-ls" ]]; then + echo 'FAIL: could not build nuwiki-ls binary' >&2 + exit 1 + fi + mkdir -p "$REPO_ROOT/bin" + ln -sf "$rs_repo/target/release/nuwiki-ls" "$BIN" +fi + +if [[ ! -x "$BIN" ]]; then + echo 'FAIL: could not obtain nuwiki-ls binary' >&2 + exit 1 +fi # --- Scratch wiki with one seeded diary entry (2026-05-30) ---------------- WIKI="$TMP/wiki" diff --git a/development/tests/test-keymaps.sh b/development/tests/test-keymaps.sh index f6ed2c0..aace301 100755 --- a/development/tests/test-keymaps.sh +++ b/development/tests/test-keymaps.sh @@ -5,7 +5,7 @@ # # The Lua harness needs a real LSP attachment (most keymaps dispatch # `workspace/executeCommand`), so we: -# 1. ensure `bin/nuwiki-ls` is built (release), +# 1. download `bin/nuwiki-ls` from the nuwiki-rs releases, # 2. spin up a scratch wiki under a temp dir, # 3. invoke `nvim --headless` with a minimal init that loads the # plugin + calls `setup()`, then sources the harness Lua. @@ -20,15 +20,34 @@ trap 'rm -rf "$TMP"' EXIT log() { printf '\033[1;34m[keymap-test]\033[0m %s\n' "$*"; } -# Always rebuild the LSP binary so harness runs against current sources. -# A previous version only built when the symlink was missing, which let -# stale binaries pass tests against unrelated code — incremental cargo -# build is fast, so just always run it. +# Download the LSP binary from nuwiki-rs releases (falls back to cargo build). BIN="$REPO_ROOT/bin/nuwiki-ls" -log 'building nuwiki-ls (release, incremental)…' -cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" >/dev/null -mkdir -p "$REPO_ROOT/bin" -ln -sf "$REPO_ROOT/target/release/nuwiki-ls" "$BIN" +log 'downloading nuwiki-ls…' +if [[ ! -x "$BIN" ]]; then + vim -e -s -c "source $REPO_ROOT/scripts/download_bin.vim" -c "q" >/dev/null 2>&1 || true +fi + +# If download failed, fall back to cargo build from nuwiki-rs. +if [[ ! -x "$BIN" ]]; then + log "download failed, falling back to cargo build from nuwiki-rs…" + rs_repo="$REPO_ROOT/nuwiki-rs" + if [[ ! -f "$rs_repo/Cargo.toml" ]]; then + log "cloning nuwiki-rs …" + git clone --depth 1 https://code.gfran.co/gffranco/nuwiki-rs.git "$rs_repo" + fi + cargo build --release -p nuwiki-ls --manifest-path "$rs_repo/Cargo.toml" >/dev/null + if [[ ! -f "$rs_repo/target/release/nuwiki-ls" ]]; then + echo 'FAIL: could not build nuwiki-ls binary' >&2 + exit 1 + fi + mkdir -p "$REPO_ROOT/bin" + ln -sf "$rs_repo/target/release/nuwiki-ls" "$BIN" +fi + +if [[ ! -x "$BIN" ]]; then + echo 'FAIL: could not obtain nuwiki-ls binary' >&2 + exit 1 +fi mkdir -p "$TMP/wiki" echo "= seed =" > "$TMP/wiki/index.wiki" diff --git a/doc/nuwiki.txt b/doc/nuwiki.txt index 68b65f5..6120334 100644 --- a/doc/nuwiki.txt +++ b/doc/nuwiki.txt @@ -50,13 +50,11 @@ Highlights: ============================================================================== 2. REQUIREMENTS *nuwiki-requirements* - * Neovim 0.11+ (preferred path) - * Vim 9.1+ with one of: - - vim-lsp (prabirshrestha/vim-lsp) - - coc.nvim (neoclide/coc.nvim) - * `curl` and `tar` (only for the binary download path; not needed if - you build from source) - * `cargo` and a Rust toolchain (only if you opt into source builds) + * Neovim 0.11+ (preferred path) + * Vim 9.1+ with one of: + - vim-lsp (prabirshrestha/vim-lsp) + - coc.nvim (neoclide/coc.nvim) + * `curl` and `tar` (for the binary download path) ============================================================================== 3. INSTALLATION *nuwiki-installation* @@ -83,16 +81,8 @@ lazy.nvim: > \ 'build': 'vim -e -s -c "source scripts/download_bin.vim" -c "q"', \ }) - - - let g:nuwiki_build_from_source = 1 - - - - vim.g.nuwiki_build_from_source = 1 - require('nuwiki').install() - -<============================================================================== +`). Note this is NOT GitHub's - -- `/releases/latest/download/` shape, which Gitea 404s. local url = string.format( - 'https://code.gfran.co/gffranco/nuwiki/releases/download/latest/nuwiki-ls-%s.tar.gz', + 'https://code.gfran.co/gffranco/nuwiki-rs/releases/download/latest/nuwiki-ls-%s.tar.gz', target ) local archive = vim.fn.tempname() .. '.tar.gz' @@ -101,9 +112,6 @@ function M.install() local dest = M.expected_path() vim.fn.mkdir(vim.fn.fnamemodify(dest, ':h'), 'p') - if vim.g.nuwiki_build_from_source == 1 then - return build_from_source(dest) - end if download_release(dest) then vim.notify('nuwiki: installed ' .. dest, vim.log.levels.INFO) return true diff --git a/scripts/download_bin.vim b/scripts/download_bin.vim index 0f6346f..fc3a467 100644 --- a/scripts/download_bin.vim +++ b/scripts/download_bin.vim @@ -1,5 +1,5 @@ " scripts/download_bin.vim — install `nuwiki-ls` from a release tarball, -" falling back to `cargo build --release` on failure. +" falling back to `cargo build --release` from the nuwiki-rs repo. " " Invoked by Dein / vim-plug build hooks: " vim -e -s -c "source scripts/download_bin.vim" -c "q" @@ -16,6 +16,7 @@ let s:plugin_dir = fnamemodify(resolve(expand(':p')), ':h:h') let s:bin_dir = s:plugin_dir . '/bin' let s:suffix = has('win32') ? '.exe' : '' let s:bin = s:bin_dir . '/nuwiki-ls' . s:suffix +let s:rs_repo = fnamemodify(s:plugin_dir, ':h:h') . '/nuwiki-rs' call mkdir(s:bin_dir, 'p') @@ -44,13 +45,21 @@ function! s:build_from_source() abort echohl ErrorMsg | echom 'nuwiki: cargo not found, cannot build from source' | echohl None return 0 endif + echom 'nuwiki: cloning nuwiki-rs …' + if !filereadable(s:rs_repo . '/Cargo.toml') + call system('git clone --depth 1 https://code.gfran.co/gffranco/nuwiki-rs.git ' . shellescape(s:rs_repo)) + if v:shell_error != 0 + echohl ErrorMsg | echom 'nuwiki: failed to clone nuwiki-rs' | echohl None + return 0 + endif + endif echom 'nuwiki: cargo build --release …' - call system('cargo build --release -p nuwiki-ls --manifest-path ' . shellescape(s:plugin_dir . '/Cargo.toml')) + call system('cargo build --release -p nuwiki-ls --manifest-path ' . shellescape(s:rs_repo . '/Cargo.toml')) if v:shell_error != 0 echohl ErrorMsg | echom 'nuwiki: cargo build failed' | echohl None return 0 endif - let l:built = s:plugin_dir . '/target/release/nuwiki-ls' . s:suffix + let l:built = s:rs_repo . '/target/release/nuwiki-ls' . s:suffix call system('cp ' . shellescape(l:built) . ' ' . shellescape(s:bin)) call system('chmod +x ' . shellescape(s:bin)) return executable(s:bin) @@ -64,10 +73,7 @@ function! s:download_release() abort if !executable('curl') || !executable('tar') return 0 endif - " Gitea serves the most recent release under the `latest` tag segment - " (/releases/download/latest/). NOT GitHub's - " /releases/latest/download/ shape, which Gitea 404s. - let l:url = 'https://code.gfran.co/gffranco/nuwiki/releases/download/latest/nuwiki-ls-' + let l:url = 'https://code.gfran.co/gffranco/nuwiki-rs/releases/download/latest/nuwiki-ls-' \ . l:target . '.tar.gz' let l:archive = tempname() . '.tar.gz' call system('curl -fsSL -o ' . shellescape(l:archive) . ' ' . shellescape(l:url)) @@ -85,11 +91,7 @@ function! s:download_release() abort return executable(s:bin) endfunction -if get(g:, 'nuwiki_build_from_source', 0) - if !s:build_from_source() - echohl ErrorMsg | echom 'nuwiki: source build failed' | echohl None - endif -elseif s:download_release() +if s:download_release() echom 'nuwiki: installed ' . s:bin else echohl WarningMsg | echom 'nuwiki: download failed, falling back to source build' | echohl None diff --git a/scripts/set-version.sh b/scripts/set-version.sh deleted file mode 100755 index 9e2c178..0000000 --- a/scripts/set-version.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -# scripts/set-version.sh — stamp a release version into every hardcoded spot. -# -# This is the single source of truth for *where* the version lives. The -# release pipeline (.gitea/workflows/release.yaml) calls it with the -# workflow-dispatch input; run it the same way to prepare a release by hand: -# -# scripts/set-version.sh 0.4.0 -# -# It rewrites: -# 1. Cargo.toml — [workspace.package] version -# 2. crates/nuwiki-lsp/Cargo.toml — nuwiki-core path-dep version pin -# 3. crates/nuwiki-ls/Cargo.toml — nuwiki-lsp path-dep version pin -# 4. plugin/nuwiki.vim — g:nuwiki_version (the Lua client reads it too) -# 5. Cargo.lock — the version of our three own crates -# -# Cargo.lock is patched directly (awk) so this needs no cargo/toolchain — -# the only thing that changes for our crates is their version string. -set -euo pipefail - -ver="${1:-}" -ver="${ver#v}" # tolerate a leading "v" -if ! printf '%s' "$ver" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$'; then - echo "usage: $0 e.g. $0 0.4.0" >&2 - exit 2 -fi - -root="$(cd "$(dirname "$0")/.." && pwd)" -cd "$root" - -# 1. Workspace package version (root Cargo.toml, [workspace.package]). -sed -i -E "s/^version = \"[0-9][^\"]*\"/version = \"$ver\"/" Cargo.toml - -# 2-3. Internal path-dep pins must track the workspace version. -sed -i -E "s|(nuwiki-core = \{ path = \"\.\./nuwiki-core\", version = )\"[^\"]*\"|\1\"$ver\"|" \ - crates/nuwiki-lsp/Cargo.toml -sed -i -E "s|(nuwiki-lsp = \{ path = \"\.\./nuwiki-lsp\", version = )\"[^\"]*\"|\1\"$ver\"|" \ - crates/nuwiki-ls/Cargo.toml - -# 4. VimL client version global (the Lua client reads this same g: var). -sed -i -E "s/(let g:nuwiki_version = ')[^']*(')/\1$ver\2/" plugin/nuwiki.vim - -# 5. Cargo.lock entries for our own crates. Each [[package]] block lists the -# name line immediately followed by the version line; rewrite only those. -for crate in nuwiki-core nuwiki-lsp nuwiki-ls; do - awk -v c="$crate" -v v="$ver" ' - $0 == "name = \"" c "\"" { - print - if (getline > 0) { sub(/version = "[^"]*"/, "version = \"" v "\"") } - print - next - } - { print } - ' Cargo.lock > Cargo.lock.tmp && mv Cargo.lock.tmp Cargo.lock -done - -echo "Set version to $ver"
              db a b c 1