Remove Rust code, delegate server to nuwiki-rs repo
Delete all Rust crates (crates/) and Cargo files. The nuwiki-ls binary is now built in the separate nuwiki-rs repository and downloaded at install time from the Gitea release assets, with a cargo build fallback that clones nuwiki-rs. Update all documentation to reflect the split repo layout.
This commit is contained in:
+3
-2
@@ -1,9 +1,10 @@
|
|||||||
# Rust build artifacts
|
# Rust build artifacts
|
||||||
/target
|
/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
|
/bin
|
||||||
|
# nuwiki-rs is cloned here during install fallback, not tracked
|
||||||
|
nuwiki-rs/
|
||||||
|
|
||||||
# Editor / OS noise
|
# Editor / OS noise
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
Generated
-835
@@ -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"
|
|
||||||
-22
@@ -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 <gffranco@gmail.com>"]
|
|
||||||
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 }
|
|
||||||
@@ -6,7 +6,8 @@ nuwiki replaces the original [vimwiki](https://github.com/vimwiki/vimwiki)
|
|||||||
plugin while keeping its file format, keymaps, and `:Vimwiki*` command
|
plugin while keeping its file format, keymaps, and `:Vimwiki*` command
|
||||||
surface intact. The substantive work happens in a Rust LSP daemon — Vim
|
surface intact. The substantive work happens in a Rust LSP daemon — Vim
|
||||||
and Neovim are thin client layers that wire up keystrokes and display
|
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
|
## Features
|
||||||
|
|
||||||
@@ -72,9 +73,12 @@ results.
|
|||||||
|
|
||||||
The plugin ships a `nuwiki-ls` binary that the editor talks to over LSP
|
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
|
stdio. The `install()` helper downloads a pre-built release for your
|
||||||
platform, falling back to `cargo build --release` if no asset matches.
|
platform from the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs)
|
||||||
You can run it any time with `:NuwikiInstall` (works in both Vim and
|
repository. If the download fails (or `curl`/`tar` are unavailable), it
|
||||||
Neovim) instead of relying on a plugin-manager build hook.
|
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
|
### Requirements
|
||||||
|
|
||||||
@@ -86,8 +90,10 @@ Neovim) instead of relying on a plugin-manager build hook.
|
|||||||
[`coc.nvim`](https://github.com/neoclide/coc.nvim) — both
|
[`coc.nvim`](https://github.com/neoclide/coc.nvim) — both
|
||||||
auto-registered, no manual config (see [Vim LSP client
|
auto-registered, no manual config (see [Vim LSP client
|
||||||
setup](#vim-lsp-client-setup)).
|
setup](#vim-lsp-client-setup)).
|
||||||
- **Rust toolchain (1.83+ stable)** — only needed when building the
|
- **curl + tar** — for downloading pre-built release binaries.
|
||||||
binary from source; pre-built release downloads skip this.
|
- **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
|
### lazy.nvim
|
||||||
|
|
||||||
@@ -126,10 +132,13 @@ call dein#add('gffranco/nuwiki', {
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://code.gfran.co/gffranco/nuwiki ~/.vim/pack/gffranco/start/nuwiki
|
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
|
cd ~/.vim/pack/gffranco/start/nuwiki && vim -e -s -c "source scripts/download_bin.vim" -c "q"
|
||||||
mkdir -p bin && cp target/release/nuwiki-ls bin/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
### Vim LSP client setup
|
||||||
|
|
||||||
Neovim 0.11+ registers the server through its built-in LSP client
|
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)
|
./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`.
|
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
|
## Configuration
|
||||||
@@ -441,7 +454,6 @@ For users configuring without Lua.
|
|||||||
| `g:nuwiki_no_folding` | `0` | `1` skips foldexpr setup |
|
| `g:nuwiki_no_folding` | `0` | `1` skips foldexpr setup |
|
||||||
| `g:nuwiki_mouse_mappings` | `0` | `1` enables mouse maps |
|
| `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_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_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_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 |
|
| `g:nuwiki_auto_header` | `0` | `1` insert a level-1 header from the filename on new wiki pages |
|
||||||
|
|||||||
@@ -1175,7 +1175,7 @@ endfunction
|
|||||||
" Mirrors `lua/nuwiki/commands.lua`'s `align_table_at` so plain Vim
|
" Mirrors `lua/nuwiki/commands.lua`'s `align_table_at` so plain Vim
|
||||||
" buffers get the same "tighten columns as cells grow" behaviour the
|
" buffers get the same "tighten columns as cells grow" behaviour the
|
||||||
" Neovim path enjoys, without needing a vim-lsp roundtrip. Algorithm
|
" 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
|
function! s:is_table_row(line) abort
|
||||||
return a:line =~# '^\s*|.*|\s*$'
|
return a:line =~# '^\s*|.*|\s*$'
|
||||||
|
|||||||
@@ -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]
|
|
||||||
@@ -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<InlineNode>,
|
|
||||||
pub centered: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ParagraphNode {
|
|
||||||
pub span: Span,
|
|
||||||
pub children: Vec<InlineNode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<BlockNode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct PreformattedNode {
|
|
||||||
pub span: Span,
|
|
||||||
pub content: String,
|
|
||||||
pub language: Option<String>,
|
|
||||||
/// Verbatim text after the opening `{{{`, trailing whitespace trimmed
|
|
||||||
/// (e.g. `python` or `class="brush: python"`). vimwiki inserts this as
|
|
||||||
/// raw attributes on the `<pre>` 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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ListNode {
|
|
||||||
pub span: Span,
|
|
||||||
pub ordered: bool,
|
|
||||||
pub symbol: ListSymbol,
|
|
||||||
pub items: Vec<ListItemNode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ListItemNode {
|
|
||||||
pub span: Span,
|
|
||||||
pub symbol: ListSymbol,
|
|
||||||
pub level: usize,
|
|
||||||
pub checkbox: Option<CheckboxState>,
|
|
||||||
pub children: Vec<InlineNode>,
|
|
||||||
pub sublist: Option<ListNode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<DefinitionItemNode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct DefinitionItemNode {
|
|
||||||
pub span: Span,
|
|
||||||
pub term: Option<Vec<InlineNode>>,
|
|
||||||
pub definitions: Vec<Vec<InlineNode>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<TableRowNode>,
|
|
||||||
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<TableAlign>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct TableRowNode {
|
|
||||||
pub span: Span,
|
|
||||||
pub cells: Vec<TableCellNode>,
|
|
||||||
pub is_header: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct TableCellNode {
|
|
||||||
pub span: Span,
|
|
||||||
pub children: Vec<InlineNode>,
|
|
||||||
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<String>,
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
@@ -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 `<br />`
|
|
||||||
/// (`= 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<InlineNode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ItalicNode {
|
|
||||||
pub span: Span,
|
|
||||||
pub children: Vec<InlineNode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct BoldItalicNode {
|
|
||||||
pub span: Span,
|
|
||||||
pub children: Vec<InlineNode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct StrikethroughNode {
|
|
||||||
pub span: Span,
|
|
||||||
pub children: Vec<InlineNode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<InlineNode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct SubscriptNode {
|
|
||||||
pub span: Span,
|
|
||||||
pub children: Vec<InlineNode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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
|
|
||||||
/// `<span style="color:…">` 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<InlineNode>,
|
|
||||||
}
|
|
||||||
@@ -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<String>,
|
|
||||||
pub wiki_index: Option<usize>,
|
|
||||||
pub wiki_name: Option<String>,
|
|
||||||
pub anchor: Option<String>,
|
|
||||||
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<Vec<InlineNode>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ExternalLinkNode {
|
|
||||||
pub span: Span,
|
|
||||||
pub url: String,
|
|
||||||
pub description: Option<Vec<InlineNode>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct TransclusionNode {
|
|
||||||
pub span: Span,
|
|
||||||
pub url: String,
|
|
||||||
pub alt: Option<String>,
|
|
||||||
pub attrs: HashMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct RawUrlNode {
|
|
||||||
pub span: Span,
|
|
||||||
pub url: String,
|
|
||||||
}
|
|
||||||
@@ -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<BlockNode>,
|
|
||||||
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<String>,
|
|
||||||
pub nohtml: bool,
|
|
||||||
pub template: Option<String>,
|
|
||||||
pub date: Option<String>,
|
|
||||||
/// File-scope tags parsed by the tag lexer. Convenience
|
|
||||||
/// projection of the `BlockNode::Tag` nodes whose scope is `File`.
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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: Visitor + ?Sized>(v: &mut V, node: &DocumentNode) {
|
|
||||||
for child in &node.children {
|
|
||||||
v.visit_block(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn walk_block<V: Visitor + ?Sized>(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: Visitor + ?Sized>(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: Visitor + ?Sized>(v: &mut V, node: &ListNode) {
|
|
||||||
for item in &node.items {
|
|
||||||
v.visit_list_item(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn walk_list_item<V: Visitor + ?Sized>(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: Visitor + ?Sized>(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: Visitor + ?Sized>(v: &mut V, node: &TableNode) {
|
|
||||||
for row in &node.rows {
|
|
||||||
v.visit_table_row(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn walk_table_row<V: Visitor + ?Sized>(v: &mut V, node: &TableRowNode) {
|
|
||||||
for cell in &node.cells {
|
|
||||||
v.visit_table_cell(cell);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
//! (<https://howardhinnant.github.io/date_algorithms.html>) — 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<Self> {
|
|
||||||
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<Self> {
|
|
||||||
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<Ordering> {
|
|
||||||
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<u32> {
|
|
||||||
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<Self> {
|
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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<char>,
|
|
||||||
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<char> = 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<usize> {
|
|
||||||
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<CheckboxState> {
|
|
||||||
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), '✓');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<dyn Renderer>` 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<String> {
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
self.render(doc, &mut buf)?;
|
|
||||||
String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<T> {
|
|
||||||
tokens: Vec<T>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> TokenStream<T> {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self { tokens: Vec::new() }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_vec(tokens: Vec<T>) -> Self {
|
|
||||||
Self { tokens }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn into_vec(self) -> Vec<T> {
|
|
||||||
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<T> From<Vec<T>> for TokenStream<T> {
|
|
||||||
fn from(tokens: Vec<T>) -> Self {
|
|
||||||
Self::from_vec(tokens)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> IntoIterator for TokenStream<T> {
|
|
||||||
type Item = T;
|
|
||||||
type IntoIter = std::vec::IntoIter<T>;
|
|
||||||
|
|
||||||
fn into_iter(self) -> Self::IntoIter {
|
|
||||||
self.tokens.into_iter()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, T> IntoIterator for &'a TokenStream<T> {
|
|
||||||
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<Self::Token>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<Self::Token>) -> 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;
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
//! Registry of syntax plugins.
|
|
||||||
//!
|
|
||||||
//! Plugins are stored behind `Arc<dyn SyntaxPlugin>` 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<Arc<dyn SyntaxPlugin>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<P>(&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<Item = &dyn SyntaxPlugin> + '_ {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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<Vec<TableCellNode>>, has_header: bool) -> DocumentNode {
|
|
||||||
let span = Span::default();
|
|
||||||
let rows: Vec<TableRowNode> = 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: <div id="hier"><h{l} id="flat" class="header">
|
|
||||||
// <a href="#hier">…</a></h{l}></div>. A lone heading has hier == flat.
|
|
||||||
assert!(out.starts_with(&format!(
|
|
||||||
"<div id=\"Title\"><h{level} id=\"Title\" class=\"header\"><a href=\"#Title\">Title</a></h{level}></div>"
|
|
||||||
)), "got: {out}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn centered_heading_gets_centered_class() {
|
|
||||||
let out = render(" = T =\n");
|
|
||||||
assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spaceless_heading_renders_as_heading() {
|
|
||||||
// `==Heading==` (no surrounding spaces) is a heading, like vimwiki — not a
|
|
||||||
// literal `<p>==Heading==</p>`.
|
|
||||||
let out = render("==Plain==\n");
|
|
||||||
assert!(
|
|
||||||
out.contains(
|
|
||||||
"<div id=\"Plain\"><h2 id=\"Plain\" class=\"header\">\
|
|
||||||
<a href=\"#Plain\">Plain</a></h2></div>"
|
|
||||||
),
|
|
||||||
"got: {out}"
|
|
||||||
);
|
|
||||||
// The spaceless + keyword combo from the report: a header, not a badge.
|
|
||||||
let out2 = render("==TODO==\n");
|
|
||||||
assert!(
|
|
||||||
out2.contains("<h2 id=\"TODO\" class=\"header\">"),
|
|
||||||
"got: {out2}"
|
|
||||||
);
|
|
||||||
assert!(!out2.contains("<p>"), "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 `<span class="todo">`
|
|
||||||
// badge. Regression for the exported header losing its header styling + id.
|
|
||||||
let out = render("== TODO ==\n");
|
|
||||||
assert!(
|
|
||||||
out.contains(
|
|
||||||
"<div id=\"TODO\"><h2 id=\"TODO\" class=\"header\">\
|
|
||||||
<a href=\"#TODO\">TODO</a></h2></div>"
|
|
||||||
),
|
|
||||||
"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("<a href=\"page.html\">Task [B-1]</a>"),
|
|
||||||
"got: {out}"
|
|
||||||
);
|
|
||||||
// Two bracketed links on one line both resolve.
|
|
||||||
let out2 = render("[[a|X [1]]] and [[b|Y [2]]]\n");
|
|
||||||
assert!(out2.contains("<a href=\"a.html\">X [1]</a>"), "got: {out2}");
|
|
||||||
assert!(out2.contains("<a href=\"b.html\">Y [2]</a>"), "got: {out2}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn paragraph_wraps_inline_content() {
|
|
||||||
let out = render("Hello world\n");
|
|
||||||
assert_eq!(out.trim(), "<p>Hello world</p>");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn horizontal_rule() {
|
|
||||||
let out = render("----\n");
|
|
||||||
assert!(out.contains("<hr>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blockquote_lines_become_paragraphs() {
|
|
||||||
let out = render("> first\n> second\n");
|
|
||||||
assert!(out.contains("<blockquote>"));
|
|
||||||
assert!(out.contains("<p>first</p>"));
|
|
||||||
assert!(out.contains("<p>second</p>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preformatted_block_emits_pre_with_raw_fence_attrs() {
|
|
||||||
// vimwiki drops the verbatim fence text into the <pre> tag (`{{{rust` →
|
|
||||||
// `<pre rust>`), with no inner <code> wrapper.
|
|
||||||
let out = render("{{{rust\nfn main() {}\n}}}\n");
|
|
||||||
assert!(out.contains("<pre rust>"), "got: {out}");
|
|
||||||
assert!(out.contains("fn main() {}"));
|
|
||||||
assert!(out.contains("</pre>"));
|
|
||||||
assert!(!out.contains("<code"), "no inner code element: {out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preformatted_block_without_attrs_is_bare_pre() {
|
|
||||||
let out = render("{{{\nplain\n}}}\n");
|
|
||||||
assert!(out.contains("<pre>plain"), "got: {out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preformatted_block_keeps_full_attr_string() {
|
|
||||||
let out = render("{{{class=\"brush: python\"\nx\n}}}\n");
|
|
||||||
assert!(out.contains("<pre class=\"brush: python\">"), "got: {out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn math_block_emits_div() {
|
|
||||||
let out = render("{{$\nx=1\n}}$\n");
|
|
||||||
assert!(out.contains("<div class=\"math-block\">"));
|
|
||||||
assert!(out.contains("x=1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unordered_list_with_checkbox() {
|
|
||||||
let out = render("- [X] done\n- not done\n");
|
|
||||||
assert!(out.contains("<ul>"));
|
|
||||||
// vimwiki-compatible: class on the <li>, no <input> (the stylesheet draws it).
|
|
||||||
assert!(out.contains("<li class=\"done4\">done</li>"), "{out}");
|
|
||||||
assert!(out.contains("<li>not done</li>"), "{out}");
|
|
||||||
assert!(!out.contains("<input"), "no input element: {out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn checkbox_states_use_vimwiki_done_classes() {
|
|
||||||
let out = render("- [ ] a\n- [.] b\n- [o] c\n- [O] d\n- [X] e\n- [-] f\n");
|
|
||||||
assert!(out.contains("<li class=\"done0\">a</li>"), "{out}");
|
|
||||||
assert!(out.contains("<li class=\"done1\">b</li>"), "{out}");
|
|
||||||
assert!(out.contains("<li class=\"done2\">c</li>"), "{out}");
|
|
||||||
assert!(out.contains("<li class=\"done3\">d</li>"), "{out}");
|
|
||||||
assert!(out.contains("<li class=\"done4\">e</li>"), "{out}");
|
|
||||||
assert!(out.contains("<li class=\"rejected\">f</li>"), "{out}");
|
|
||||||
assert!(!out.contains("task-"), "no legacy task-* classes: {out}");
|
|
||||||
assert!(!out.contains("<input"), "no input elements: {out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ordered_list() {
|
|
||||||
let out = render("1. first\n");
|
|
||||||
assert!(out.contains("<ol>"));
|
|
||||||
assert!(out.contains("<li>first</li>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nested_list_renders_recursively() {
|
|
||||||
let out = render("- top\n - nested\n");
|
|
||||||
assert!(out.contains("<ul>"));
|
|
||||||
let inner_idx = out
|
|
||||||
.find("<ul>\n<li>top")
|
|
||||||
.map(|i| i + "<ul>\n<li>top".len())
|
|
||||||
.unwrap_or(0);
|
|
||||||
assert!(out[inner_idx..].contains("<ul>"));
|
|
||||||
assert!(out.contains("nested"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn definition_list() {
|
|
||||||
let out = render("Term:: Defn\n");
|
|
||||||
assert!(out.contains("<dl>"));
|
|
||||||
assert!(out.contains("<dt>Term</dt>"));
|
|
||||||
assert!(out.contains("<dd>Defn</dd>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn table_with_header_separator() {
|
|
||||||
let out = render("| a | b |\n|---|---|\n| 1 | 2 |\n");
|
|
||||||
assert!(out.contains("<table>"));
|
|
||||||
assert!(out.contains("<thead>"));
|
|
||||||
assert!(out.contains("<th> a </th>"));
|
|
||||||
assert!(out.contains("<tbody>"));
|
|
||||||
assert!(out.contains("<td> 1 </td>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn single_comment_becomes_html_comment() {
|
|
||||||
let out = render("%% hidden\n");
|
|
||||||
assert!(out.contains("<!--"));
|
|
||||||
assert!(out.contains("hidden"));
|
|
||||||
assert!(out.contains("-->"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Inline =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bold_italic_strike_super_sub() {
|
|
||||||
let out = render("*b* _i_ ~~s~~ ^sup^ ,,sub,,\n");
|
|
||||||
assert!(out.contains("<strong>b</strong>"));
|
|
||||||
assert!(out.contains("<em>i</em>"));
|
|
||||||
assert!(out.contains("<del>s</del>"));
|
|
||||||
assert!(out.contains("<sup>sup</sup>"));
|
|
||||||
assert!(out.contains("<sub>sub</sub>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn inline_code_is_escaped() {
|
|
||||||
let out = render("Use `Vec<u32>` here\n");
|
|
||||||
assert!(out.contains("<code>Vec<u32></code>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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", "<span class=\"todo\">TODO</span>"),
|
|
||||||
("DONE x\n", "<span class=\"done\">DONE</span>"),
|
|
||||||
("STARTED x\n", "<span class=\"started\">STARTED</span>"),
|
|
||||||
("FIXME x\n", "<span class=\"fixme\">FIXME</span>"),
|
|
||||||
("FIXED x\n", "<span class=\"fixed\">FIXED</span>"),
|
|
||||||
("XXX x\n", "<span class=\"xxx\">XXX</span>"),
|
|
||||||
("STOPPED x\n", "<span class=\"stopped\">STOPPED</span>"),
|
|
||||||
];
|
|
||||||
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("<a href=\"https://example.com\">https://example.com</a>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wikilink_with_default_resolver() {
|
|
||||||
let out = render("[[Page]]\n");
|
|
||||||
assert!(out.contains("<a href=\"Page.html\">Page</a>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wikilink_with_anchor_default_resolver() {
|
|
||||||
let out = render("[[Page#Section]]\n");
|
|
||||||
assert!(out.contains("<a href=\"Page.html#Section\">"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn anchor_only_link() {
|
|
||||||
let out = render("[[#Section]]\n");
|
|
||||||
assert!(out.contains("<a href=\"#Section\">"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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(
|
|
||||||
"<div id=\"My Section\"><h1 id=\"My Section\" class=\"header\">\
|
|
||||||
<a href=\"#My Section\">My Section</a></h1></div>"
|
|
||||||
),
|
|
||||||
"heading id: {out}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
out.contains("<a href=\"#My Section\">"),
|
|
||||||
"anchor href: {out}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn toc_header_heading_wrapped_in_div_toc() {
|
|
||||||
// The TOC section heading gets a `<div class="toc">` 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("<div class=\"toc\"><h2 id=\"Contents\">Contents</h2></div>"),
|
|
||||||
"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(
|
|
||||||
"<div id=\"Intro\"><h2 id=\"Intro\" class=\"header\">\
|
|
||||||
<a href=\"#Intro\">Intro</a></h2></div>"
|
|
||||||
),
|
|
||||||
"got: {out}"
|
|
||||||
);
|
|
||||||
assert!(!out.contains("class=\"toc\""), "got: {out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn interwiki_numbered_link() {
|
|
||||||
let out = render("[[wiki1:Page]]\n");
|
|
||||||
assert!(out.contains("<a href=\"../wiki1/Page.html\">"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn interwiki_named_link() {
|
|
||||||
let out = render("[[wn.Notes:Page]]\n");
|
|
||||||
assert!(out.contains("<a href=\"../wn-Notes/Page.html\">"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn diary_link() {
|
|
||||||
let out = render("[[diary:2026-05-10]]\n");
|
|
||||||
assert!(out.contains("<a href=\"diary/2026-05-10.html\">"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn external_link_routes_to_external_link_node() {
|
|
||||||
let out = render("[[https://example.com|docs]]\n");
|
|
||||||
assert!(out.contains("<a href=\"https://example.com\">docs</a>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn transclusion_becomes_img() {
|
|
||||||
let out = render("{{img.png|cat photo|class=hi}}\n");
|
|
||||||
assert!(out.contains("<img src=\"img.png\""));
|
|
||||||
assert!(out.contains("alt=\"cat photo\""));
|
|
||||||
assert!(out.contains("class=\"hi\""));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== HTML escaping =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn html_special_chars_in_text_are_escaped() {
|
|
||||||
let out = render("a < b > 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("<a href=\"/wiki/Page\">"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 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(
|
|
||||||
"<!doctype html><html><head><title>{{title}}</title></head>\
|
|
||||||
<body>{{content}}</body></html>",
|
|
||||||
);
|
|
||||||
let out = renderer.render_to_string(&doc).unwrap();
|
|
||||||
assert!(out.contains("<title>My Page</title>"));
|
|
||||||
assert!(out.contains("<body><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn template_with_missing_title_substitutes_empty_string() {
|
|
||||||
let doc = VimwikiSyntax::new().parse("= Heading =\n");
|
|
||||||
let renderer = HtmlRenderer::new().with_template("<title>{{title}}</title>{{content}}");
|
|
||||||
let out = renderer.render_to_string(&doc).unwrap();
|
|
||||||
assert!(out.contains("<title></title>"));
|
|
||||||
assert!(out.contains("<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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>%title%</title><link href=\"%root_path%style.css\"><body>%content%</body>",
|
|
||||||
)
|
|
||||||
.with_var("root_path", "../");
|
|
||||||
let out = renderer.render_to_string(&doc).unwrap();
|
|
||||||
assert!(out.contains("<title>My Page</title>"), "title: {out}");
|
|
||||||
assert!(
|
|
||||||
out.contains("<body><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>"),
|
|
||||||
"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("<span style=\"color:crimson\">hi</span>"),
|
|
||||||
"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("<em data-style=\"__STYLE__\">__CONTENT__</em>");
|
|
||||||
let out = renderer.render_to_string(&doc).unwrap();
|
|
||||||
assert!(
|
|
||||||
out.contains("<em data-style=\"color:crimson\">hi</em>"),
|
|
||||||
"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("<span class=\"color-plaid\">hi</span>"),
|
|
||||||
"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 [
|
|
||||||
"<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>",
|
|
||||||
"<strong>bold</strong>",
|
|
||||||
"<em>italic</em>",
|
|
||||||
"<code>code</code>",
|
|
||||||
"<ul>",
|
|
||||||
"<blockquote>",
|
|
||||||
"<hr>",
|
|
||||||
"<table>",
|
|
||||||
"<pre rust>",
|
|
||||||
] {
|
|
||||||
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#"<td colspan="2">d</td>"#), "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#"<td rowspan="2">b</td>"#), "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="));
|
|
||||||
}
|
|
||||||
@@ -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<VimwikiTokenKind> {
|
|
||||||
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<VimwikiTokenKind> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -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 <ul>, two <li> entries — the bug we're fixing produced
|
|
||||||
// two adjacent <ul> elements with an interleaved <p>.
|
|
||||||
assert_eq!(out.matches("<ul>").count(), 1, "got: {out}");
|
|
||||||
assert_eq!(out.matches("<li>").count(), 2, "got: {out}");
|
|
||||||
assert!(!out.contains("<p>"), "no orphan paragraph; got: {out}");
|
|
||||||
assert!(out.contains("first item continuing here"), "got: {out}");
|
|
||||||
}
|
|
||||||
@@ -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(_)));
|
|
||||||
}
|
|
||||||
@@ -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<MockToken> {
|
|
||||||
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<MockToken>) -> DocumentNode {
|
|
||||||
let joined = tokens
|
|
||||||
.iter()
|
|
||||||
.map(|t| t.0.as_str())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.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::<u32>::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::<u32>(), 6);
|
|
||||||
assert_eq!((&s).into_iter().sum::<u32>(), 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<T: Send + Sync>() {}
|
|
||||||
assert_send_sync::<SyntaxRegistry>();
|
|
||||||
}
|
|
||||||
@@ -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#"<th style="text-align: left;"> a </th>"#),
|
|
||||||
"got: {out}"
|
|
||||||
);
|
|
||||||
assert!(out.contains(r#"<th style="text-align: right;"> b </th>"#));
|
|
||||||
assert!(out.contains(r#"<th style="text-align: center;"> c </th>"#));
|
|
||||||
assert!(out.contains(r#"<td style="text-align: left;"> 1 </td>"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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"));
|
|
||||||
}
|
|
||||||
@@ -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<VimwikiTokenKind> {
|
|
||||||
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("<div class=\"tags\">"));
|
|
||||||
// Bare tag name as the id (vimwiki parity), so `[[Page#todo]]` resolves.
|
|
||||||
assert!(html.contains("<span class=\"tag\" id=\"todo\">todo</span>"));
|
|
||||||
assert!(html.contains("<span class=\"tag\" id=\"done\">done</span>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<b:\n");
|
|
||||||
assert!(html.contains("a<b"));
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
//! Cluster 8 — transclusion attribute parsing. The basic
|
|
||||||
//! `{{url|alt|key=val}}` shape was already wired; this test pins
|
|
||||||
//! down quote-stripping on quoted values (`key="quoted value"`)
|
|
||||||
//! so an HTML renderer doesn't double-emit `"` artefacts.
|
|
||||||
|
|
||||||
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 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("""));
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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"] }
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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<DiagnosticSeverity> {
|
|
||||||
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<BrokenLinkKind> {
|
|
||||||
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<BrokenLinkKind> {
|
|
||||||
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<PathBuf> {
|
|
||||||
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<Diagnostic> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
//! `<wiki_root>/<diary_rel_path>/`. 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> {
|
|
||||||
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> {
|
|
||||||
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> {
|
|
||||||
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<DiaryEntry> {
|
|
||||||
let mut out: Vec<DiaryEntry> = 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<i32>,
|
|
||||||
month: Option<u8>,
|
|
||||||
) -> Vec<DiaryEntry> {
|
|
||||||
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<DiaryEntry> {
|
|
||||||
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<DiaryEntry> {
|
|
||||||
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
|
|
||||||
/// `<C-Down>` 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<PeriodEntry> {
|
|
||||||
let mut out: Vec<PeriodEntry> = 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<PeriodEntry> {
|
|
||||||
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<PeriodEntry> {
|
|
||||||
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
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<i32> = None;
|
|
||||||
let mut current_month: Option<u8> = 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
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",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Url, Vec<TextEdit>>,
|
|
||||||
ops: Vec<DocumentChangeOperation>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<DocumentChangeOperation> = 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::<usize>() as u32
|
|
||||||
}
|
|
||||||
@@ -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<String> = 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. `<template_path>/<doc.metadata.template>.<template_ext>` if set.
|
|
||||||
/// 2. `<template_path>/<template_default>.<template_ext>`.
|
|
||||||
/// 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<PathBuf>,
|
|
||||||
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#"<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>{{{{title}}}}</title>
|
|
||||||
<link rel="stylesheet" href="{{{{root_path}}}}{css_name}">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{{{{content}}}}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"#
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 `<ul>` 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("<ul class=\"toc\">");
|
|
||||||
let mut depth = 0usize;
|
|
||||||
for h in &headings {
|
|
||||||
let target = h.level.saturating_sub(min_level) as usize;
|
|
||||||
while depth < target {
|
|
||||||
out.push_str("<ul>");
|
|
||||||
depth += 1;
|
|
||||||
}
|
|
||||||
while depth > target {
|
|
||||||
out.push_str("</ul>");
|
|
||||||
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!("<li><a href=\"#{anchor}\">{anchor}</a></li>"));
|
|
||||||
}
|
|
||||||
while depth > 0 {
|
|
||||||
out.push_str("</ul>");
|
|
||||||
depth -= 1;
|
|
||||||
}
|
|
||||||
out.push_str("</ul>");
|
|
||||||
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 `<link>`/`<script>` hrefs still find
|
|
||||||
/// the shared CSS at the export root.
|
|
||||||
pub fn compute_root_path(name: &str) -> String {
|
|
||||||
let depth = name.matches('/').count();
|
|
||||||
if depth == 0 {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
let mut s = String::with_capacity(depth * 3);
|
|
||||||
for _ in 0..depth {
|
|
||||||
s.push_str("../");
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True if `name` matches any of the `exclude_files` glob patterns.
|
|
||||||
/// Supported wildcards: `*` (any segment chars except `/`), `**` (any
|
|
||||||
/// segments including `/`), `?` (one char). Anything else is literal.
|
|
||||||
pub fn is_excluded(patterns: &[String], name: &str) -> bool {
|
|
||||||
patterns.iter().any(|p| glob_match(p, name))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure HTML render pass. The caller has already loaded the template
|
|
||||||
/// (or falls back). Wires up `{{date}}`, `{{root_path}}`, `{{toc}}`
|
|
||||||
/// plus arbitrary extras passed via `extra_vars`.
|
|
||||||
///
|
|
||||||
/// `wiki_extension` is the wiki's source file extension (e.g. `".wiki"`).
|
|
||||||
/// When set, it is stripped from wiki/interwiki link targets so a link
|
|
||||||
/// written with the extension (`[[todo.wiki]]`) exports to `todo.html`,
|
|
||||||
/// matching how the LSP resolves the same link for in-editor navigation.
|
|
||||||
// Every parameter is a distinct, cohesive render input; bundling them into a
|
|
||||||
// struct would add an abstraction without simplifying the single caller.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn render_page_html(
|
|
||||||
doc: &DocumentNode,
|
|
||||||
template: Option<String>,
|
|
||||||
name: &str,
|
|
||||||
today: DiaryDate,
|
|
||||||
cfg: &HtmlConfig,
|
|
||||||
wiki_extension: Option<&str>,
|
|
||||||
extra_vars: HashMap<String, String>,
|
|
||||||
) -> std::io::Result<String> {
|
|
||||||
let date_str = match doc.metadata.date.as_deref() {
|
|
||||||
Some(s) => match DiaryDate::parse(s) {
|
|
||||||
Some(d) => format_date(&d, &cfg.template_date_format),
|
|
||||||
None => s.to_string(),
|
|
||||||
},
|
|
||||||
None => format_date(&today, &cfg.template_date_format),
|
|
||||||
};
|
|
||||||
let root_path = compute_root_path(name);
|
|
||||||
let toc = build_toc_html(doc);
|
|
||||||
|
|
||||||
let mut vars: HashMap<String, String> = HashMap::new();
|
|
||||||
vars.insert("date".into(), date_str);
|
|
||||||
vars.insert("root_path".into(), root_path);
|
|
||||||
vars.insert("toc".into(), toc);
|
|
||||||
vars.insert("css".into(), cfg.css_name.clone());
|
|
||||||
// vimwiki names the stylesheet placeholder `%wiki_css%`; alias it so stock
|
|
||||||
// templates resolve. nuwiki's own templates use `{{css}}`.
|
|
||||||
vars.insert("wiki_css".into(), cfg.css_name.clone());
|
|
||||||
for (k, v) in extra_vars {
|
|
||||||
vars.insert(k, v);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut r = HtmlRenderer::new();
|
|
||||||
{
|
|
||||||
// Same-page anchor links (`[[#Section]]`) resolve to the *current*
|
|
||||||
// page's html file plus the fragment (`index.html#Section`), matching
|
|
||||||
// vimwiki — rather than a bare `#Section`.
|
|
||||||
let current_html = format!("{}.html", name.rsplit('/').next().unwrap_or(name));
|
|
||||||
// Strip the wiki extension from page links before the default resolver
|
|
||||||
// turns them into `.html` URLs, so `[[todo.wiki]]` → `todo.html` rather
|
|
||||||
// than `todo.wiki.html`. Only wiki/interwiki targets are touched;
|
|
||||||
// file:/local: paths keep their literal extension.
|
|
||||||
let ext = wiki_extension
|
|
||||||
.filter(|e| !e.trim_start_matches('.').is_empty())
|
|
||||||
.map(|e| e.to_string());
|
|
||||||
r = r.with_link_resolver(move |target| {
|
|
||||||
if matches!(target.kind, LinkKind::AnchorOnly) {
|
|
||||||
return match &target.anchor {
|
|
||||||
Some(a) => format!("{current_html}#{a}"),
|
|
||||||
None => current_html.clone(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let mut t = target.clone();
|
|
||||||
if let Some(ext) = &ext {
|
|
||||||
if matches!(t.kind, LinkKind::Wiki | LinkKind::Interwiki) {
|
|
||||||
if let Some(p) = t.path.take() {
|
|
||||||
t.path =
|
|
||||||
Some(crate::index::strip_wiki_extension(&p, Some(ext)).to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nuwiki_core::render::html::default_link_resolver(&t)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Some(t) = template {
|
|
||||||
r = r.with_template(t);
|
|
||||||
}
|
|
||||||
r = r.with_vars(vars);
|
|
||||||
if !cfg.color_dic.is_empty() {
|
|
||||||
r = r.with_colors(cfg.color_dic.clone());
|
|
||||||
}
|
|
||||||
if !cfg.color_tag_template.is_empty() {
|
|
||||||
r = r.with_color_template(cfg.color_tag_template.clone());
|
|
||||||
}
|
|
||||||
if cfg.html_header_numbering > 0 {
|
|
||||||
r = r.with_header_numbering(
|
|
||||||
cfg.html_header_numbering,
|
|
||||||
cfg.html_header_numbering_sym.clone(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
r = r.with_toc_header(&cfg.toc_header);
|
|
||||||
if !cfg.valid_html_tags.is_empty() {
|
|
||||||
r = r.with_valid_html_tags(cfg.valid_html_tags.clone());
|
|
||||||
}
|
|
||||||
r = r.with_emoji(cfg.emoji_enable);
|
|
||||||
r = r.with_newline_handling(cfg.text_ignore_newline, cfg.list_ignore_newline);
|
|
||||||
r.render_to_string(doc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Internals =====
|
|
||||||
|
|
||||||
fn slugify(s: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(s.len());
|
|
||||||
let mut prev_dash = false;
|
|
||||||
for ch in s.chars() {
|
|
||||||
if ch.is_alphanumeric() {
|
|
||||||
for c in ch.to_lowercase() {
|
|
||||||
out.push(c);
|
|
||||||
}
|
|
||||||
prev_dash = false;
|
|
||||||
} else if !prev_dash && !out.is_empty() {
|
|
||||||
out.push('-');
|
|
||||||
prev_dash = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while out.ends_with('-') {
|
|
||||||
out.pop();
|
|
||||||
}
|
|
||||||
if out.is_empty() {
|
|
||||||
"untitled".into()
|
|
||||||
} else {
|
|
||||||
out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_headings(doc: &DocumentNode) -> Vec<HeadingProj> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for b in &doc.children {
|
|
||||||
if let BlockNode::Heading(h) = b {
|
|
||||||
out.push(HeadingProj {
|
|
||||||
level: h.level,
|
|
||||||
title: nuwiki_core::ast::inline_text(&h.children),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HeadingProj {
|
|
||||||
level: u8,
|
|
||||||
title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn escape_html(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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal glob match for `exclude_files`. Handles `*`, `**`, `?` and
|
|
||||||
/// literal characters. No character classes — those aren't needed for
|
|
||||||
/// "exclude foo/*.tmp" style patterns.
|
|
||||||
pub(crate) fn glob_match(pattern: &str, text: &str) -> bool {
|
|
||||||
glob_impl(pattern.as_bytes(), text.as_bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn glob_impl(p: &[u8], t: &[u8]) -> bool {
|
|
||||||
let mut pi = 0;
|
|
||||||
let mut ti = 0;
|
|
||||||
let mut star: Option<(usize, usize)> = None;
|
|
||||||
let mut globstar = false;
|
|
||||||
while ti < t.len() {
|
|
||||||
if pi < p.len() && pi + 1 < p.len() && p[pi] == b'*' && p[pi + 1] == b'*' {
|
|
||||||
globstar = true;
|
|
||||||
star = Some((pi, ti));
|
|
||||||
pi += 2;
|
|
||||||
// Allow optional `/` after globstar.
|
|
||||||
if pi < p.len() && p[pi] == b'/' {
|
|
||||||
pi += 1;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
} else if pi < p.len() && p[pi] == b'*' {
|
|
||||||
star = Some((pi, ti));
|
|
||||||
pi += 1;
|
|
||||||
globstar = false;
|
|
||||||
continue;
|
|
||||||
} else if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
|
|
||||||
pi += 1;
|
|
||||||
ti += 1;
|
|
||||||
continue;
|
|
||||||
} else if let Some((s_pi, s_ti)) = star {
|
|
||||||
if !globstar && t[s_ti] == b'/' && t[ti] != b'/' {
|
|
||||||
// single-star can't cross a `/`
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
pi = if globstar { s_pi + 2 } else { s_pi + 1 };
|
|
||||||
ti = s_ti + 1;
|
|
||||||
star = Some((s_pi, ti));
|
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while pi < p.len() && (p[pi] == b'*' || (p[pi] == b'/' && globstar)) {
|
|
||||||
pi += 1;
|
|
||||||
}
|
|
||||||
pi == p.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `<html_path>/<css_name>` — the absolute path of the CSS file the
|
|
||||||
/// renderer references via `{{root_path}}{{css}}`.
|
|
||||||
pub fn css_path(cfg: &HtmlConfig) -> PathBuf {
|
|
||||||
cfg.html_path.join(&cfg.css_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default CSS written by `nuwiki.export.*` commands when no CSS file exists
|
|
||||||
/// yet at [`css_path`]. This is vimwiki's stock `style.css` verbatim (MIT
|
|
||||||
/// licensed) so exports look identical to upstream out of the box and the
|
|
||||||
/// `.header` / `.tag` / `.toc` / `done*` classes our HTML emits are styled.
|
|
||||||
pub const DEFAULT_CSS: &str = r#"body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;;
|
|
||||||
margin: 2em 4em 2em 4em;
|
|
||||||
font-size: 120%;
|
|
||||||
line-height: 130%;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
font-weight: bold;
|
|
||||||
line-height:100%;
|
|
||||||
margin-top: 1.5em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {font-size: 2em; color: #000000;}
|
|
||||||
h2 {font-size: 1.8em; color: #404040;}
|
|
||||||
h3 {font-size: 1.6em; color: #707070;}
|
|
||||||
h4 {font-size: 1.4em; color: #909090;}
|
|
||||||
h5 {font-size: 1.2em; color: #989898;}
|
|
||||||
h6 {font-size: 1em; color: #9c9c9c;}
|
|
||||||
|
|
||||||
p, pre, blockquote, table, ul, ol, dl {
|
|
||||||
margin-top: 1em;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul ul, ul ol, ol ol, ol ul {
|
|
||||||
margin-top: 0.5em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
li { margin: 0.3em auto; }
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin-left: 2em;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
dt { font-weight: bold; }
|
|
||||||
|
|
||||||
img { border: none; }
|
|
||||||
|
|
||||||
pre {
|
|
||||||
border-left: 5px solid #dcdcdc;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
padding-left: 1em;
|
|
||||||
font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
|
|
||||||
font-size: 0.8em;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p > a {
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.7em;
|
|
||||||
padding: 3px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background-color: #1e90ff;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
p > a:hover {
|
|
||||||
color: #dcdcdc;
|
|
||||||
background-color: #484848;
|
|
||||||
}
|
|
||||||
|
|
||||||
li > a {
|
|
||||||
color: #1e90ff;
|
|
||||||
font-weight: bold;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
li > a:hover { color: #ff4500; }
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
color: #686868;
|
|
||||||
font-size: 0.8em;
|
|
||||||
line-height: 120%;
|
|
||||||
padding: 0.8em;
|
|
||||||
border-left: 5px solid #dcdcdc;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
padding: 0.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
th { background-color: #f0f0f0; }
|
|
||||||
|
|
||||||
hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid #ccc;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
del {
|
|
||||||
text-decoration: line-through;
|
|
||||||
color: #777777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toc li { list-style-type: none; }
|
|
||||||
|
|
||||||
.todo {
|
|
||||||
font-weight: bold;
|
|
||||||
background-color: #ff4500 ;
|
|
||||||
color: white;
|
|
||||||
font-size: 0.8em;
|
|
||||||
padding: 3px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.justleft { text-align: left; }
|
|
||||||
.justright { text-align: right; }
|
|
||||||
.justcenter { text-align: center; }
|
|
||||||
|
|
||||||
.center {
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
background-color: #eeeeee;
|
|
||||||
font-family: monospace;
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header a {
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* classes for items of todo lists */
|
|
||||||
|
|
||||||
.rejected {
|
|
||||||
/* list-style: none; */
|
|
||||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAMAAAAMCGV4AAAACXBIWXMAAADFAAAAxQEdzbqoAAAAB3RJTUUH4QgEFhAtuWgv9wAAAPZQTFRFmpqam5iYnJaWnJeXnpSUn5OTopCQpoqKpouLp4iIqIiIrYCAt3V1vW1tv2xsmZmZmpeXnpKS/x4e/x8f/yAg/yIi/yQk/yUl/yYm/ygo/ykp/yws/zAw/zIy/zMz/zQ0/zU1/zY2/zw8/0BA/0ZG/0pK/1FR/1JS/1NT/1RU/1VV/1ZW/1dX/1pa/15e/19f/2Zm/2lp/21t/25u/3R0/3p6/4CA/4GB/4SE/4iI/46O/4+P/52d/6am/6ur/66u/7Oz/7S0/7e3/87O/9fX/9zc/93d/+Dg/+vr/+3t/+/v//Dw//Ly//X1//f3//n5//z8////gzaKowAAAA90Uk5T/Pz8/Pz8/Pz8/Pz8/f39ppQKWQAAAAFiS0dEEnu8bAAAAACuSURBVAhbPY9ZF4FQFEZPSKbIMmWep4gMGTKLkIv6/3/GPbfF97b3w17rA0kQOPgvAeHW6uJ6+5h7HqLdwowgOzejXRXBdx6UdSquml4xuOMBHHNU0clTzeSUA6EhF8V8kqroluMiU6HKcuf4phGPr1o2q9kYZWwNq1qfRRmTaXpqsyjj17KkWCxKBUBgXWueHIyiAIg18gsse4KHkLF5IKIY10WQgv7fOy4ST34BRiopZ8WLNrgAAAAASUVORK5CYII=);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: 0 .2em;
|
|
||||||
padding-left: 1.5em;
|
|
||||||
}
|
|
||||||
.done0 {
|
|
||||||
/* list-style: none; */
|
|
||||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAA7SURBVCiR7dMxEgAgCANBI3yVRzF5KxNbW6wsuH7LQ2YKQK1mkswBVERYF5Os3UV3gwd/jF2SkXy66gAZkxS6BniubAAAAABJRU5ErkJggg==);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: 0 .2em;
|
|
||||||
padding-left: 1.5em;
|
|
||||||
}
|
|
||||||
.done1 {
|
|
||||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABtSURBVCiR1ZO7DYAwDER9BDmTeZQMFXmUbGYpOjrEryA0wOvO8itOslFrJYAug5BMM4BeSkmjsrv3aVTa8p48Xw1JSkSsWVUFwD05IqS1tmYzk5zzae9jnVVVzGyXb8sALjse+euRkEzu/uirFomVIdDGOLjuAAAAAElFTkSuQmCC);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: 0 .15em;
|
|
||||||
padding-left: 1.5em;
|
|
||||||
}
|
|
||||||
.done2 {
|
|
||||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAB1SURBVCiRzdO5DcAgDAVQGxjAYgTvxlDIu1FTIRYAp8qlFISkSH7l5kk+ZIwxKiI2mIyqWoeILYRgZ7GINDOLjnmF3VqklKCUMgTee2DmM661Qs55iI3Zm/1u5h9sm4ig9z4ERHTFzLyd4G4+nFlVrYg8+qoF/c0kdpeMsmcAAAAASUVORK5CYII=);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: 0 .15em;
|
|
||||||
padding-left: 1.5em;
|
|
||||||
}
|
|
||||||
.done3 {
|
|
||||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABoSURBVCiR7dOxDcAgDATA/0DtUdiKoZC3YhLkHjkVKF3idJHiztKfvrHZWnOSE8Fx95RJzlprimJVnXktvXeY2S0SEZRSAAAbmxnGGKH2I5T+8VfxPhIReQSuuY3XyYWa3T2p6quvOgGrvSFGlewuUAAAAABJRU5ErkJggg==);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: 0 .15em;
|
|
||||||
padding-left: 1.5em;
|
|
||||||
}
|
|
||||||
.done4 {
|
|
||||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAQCAYAAAAbBi9cAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAzgAAAM4BlP6ToAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAIISURBVDiNnZQ9SFtRFMd/773kpTaGJoQk1im4VDpWQcTNODhkFBcVTCNCF0NWyeDiIIiCm82QoIMIUkHUxcFBg1SEQoZszSat6cdTn1qNue92CMbEr9Of5+vd8/3nPux/3O+f8h6ukUil3sVg0+M+4cFxk42/jH2wAqqqKSCSiPQdwcHHAnDHH9s/tN1h8V28ETdP+eU8fT9Nt62ancYdIPvJNtsu87bmjrJlrTDVM4RROJs1JrHPrD4Bar7A6cpc54iKOaTdJXCUI2UMVrQZ0Js7YPN18ECKkYNQcJe/OE/4dZsw7VqNXQMvHy3QZXQypQ6ycrtwDjf8aJ+PNEDSCzLpn7+m2pD8ZKHlKarYhy6XjEoCYGcN95qansQeA3fNdki+SaJZGTMQIOoL3W/Z89rxv+tokubNajlvk/vm+LFpF2XnUKZHI0I+QrI7Dw0OZTqdzUkpsM7mZTyfy5OPGyw1tK7AFSvmB/Ks8w8YwbUYbe6/3QEKv0vugfxWPnMLJun+d/kI/WLdizpNjMbAIKrhMF4OuwadBALqqs+RfInwUvuNi+fBd+wjogfogAFVRmffO02q01mZZ0HHdgXIzdz0QQLPezIQygX6llxNKKgOFARYCC49CqhoHIUTlss/Vx2phlYwjw8j1CAlfAiwQiJpiy7o1VHnsG5FISkoJu7Q/2YmmaV+i0ei7v38L2CBguSi5AAAAAElFTkSuQmCC);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: 0 .15em;
|
|
||||||
padding-left: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
|
|
||||||
-webkit-border-radius: 1px;
|
|
||||||
-moz-border-radius: 1px;
|
|
||||||
border-radius: 1px;
|
|
||||||
-moz-background-clip: padding;
|
|
||||||
-webkit-background-clip: padding-box;
|
|
||||||
background-clip: padding-box;
|
|
||||||
padding: 0px 3px;
|
|
||||||
display: inline-block;
|
|
||||||
color: #52595d;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
//! `textDocument/foldingRange` provider.
|
|
||||||
//!
|
|
||||||
//! Strategy: one fold per heading (line → line before the next heading
|
|
||||||
//! of same-or-higher level) plus one fold per top-level list block.
|
|
||||||
//! Folding is deliberately conservative — we never produce nested folds
|
|
||||||
//! beyond what the AST already groups, since vimwiki users mostly want
|
|
||||||
//! "collapse this section" behaviour rather than fine-grained code-fold
|
|
||||||
//! semantics.
|
|
||||||
|
|
||||||
use nuwiki_core::ast::{BlockNode, DocumentNode};
|
|
||||||
use tower_lsp::lsp_types::{FoldingRange, FoldingRangeKind};
|
|
||||||
|
|
||||||
/// Compute folding ranges for the whole document. `total_lines` is the
|
|
||||||
/// number of lines in the source (`text.lines().count() as u32`) — used
|
|
||||||
/// to extend the last heading's fold to end-of-document.
|
|
||||||
pub fn folding_ranges(ast: &DocumentNode, total_lines: u32) -> Vec<FoldingRange> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let last_line = total_lines.saturating_sub(1);
|
|
||||||
|
|
||||||
// Collect heading spans + levels in source order. We walk the
|
|
||||||
// top-level children only — nested headings inside blockquotes are
|
|
||||||
// rare and folding them adds complexity for little gain.
|
|
||||||
let mut headings: Vec<(u32, u8)> = Vec::new();
|
|
||||||
for block in &ast.children {
|
|
||||||
if let BlockNode::Heading(h) = block {
|
|
||||||
headings.push((h.span.start.line, h.level));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For each heading, end-line is one before the next heading whose
|
|
||||||
// level is <= current level (or last_line otherwise).
|
|
||||||
for (i, &(line, level)) in headings.iter().enumerate() {
|
|
||||||
let end = headings[i + 1..]
|
|
||||||
.iter()
|
|
||||||
.find(|(_, l)| *l <= level)
|
|
||||||
.map(|(next_line, _)| next_line.saturating_sub(1))
|
|
||||||
.unwrap_or(last_line);
|
|
||||||
if end > line {
|
|
||||||
out.push(FoldingRange {
|
|
||||||
start_line: line,
|
|
||||||
end_line: end,
|
|
||||||
start_character: None,
|
|
||||||
end_character: None,
|
|
||||||
kind: Some(FoldingRangeKind::Region),
|
|
||||||
collapsed_text: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fold each top-level list. Sublists fold implicitly because their
|
|
||||||
// parent item's source span already covers them.
|
|
||||||
for block in &ast.children {
|
|
||||||
if let BlockNode::List(l) = block {
|
|
||||||
let start = l.span.start.line;
|
|
||||||
let end = l.span.end.line;
|
|
||||||
if end > start {
|
|
||||||
out.push(FoldingRange {
|
|
||||||
start_line: start,
|
|
||||||
end_line: end,
|
|
||||||
start_character: None,
|
|
||||||
end_character: None,
|
|
||||||
kind: Some(FoldingRangeKind::Region),
|
|
||||||
collapsed_text: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Count lines in `text` (always ≥ 1 for non-empty input).
|
|
||||||
pub fn line_count(text: &str) -> u32 {
|
|
||||||
if text.is_empty() {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
let mut n = text.bytes().filter(|b| *b == b'\n').count() as u32;
|
|
||||||
if !text.ends_with('\n') {
|
|
||||||
n += 1;
|
|
||||||
} else {
|
|
||||||
// trailing newline still bounds a virtual line; LSP positions
|
|
||||||
// index 0-based, so the file ends at line `n` (the empty one
|
|
||||||
// after the final newline). We treat "lines" as the count
|
|
||||||
// *including* that trailing empty line for fold-end clamping.
|
|
||||||
n += 1;
|
|
||||||
}
|
|
||||||
n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,656 +0,0 @@
|
|||||||
//! Workspace index — the in-memory model of every `.wiki` page nuwiki
|
|
||||||
//! has seen, used to power nav features.
|
|
||||||
//!
|
|
||||||
//! The initial scan runs as a background tokio task so
|
|
||||||
//! the server stays responsive on startup. Per-document updates land here
|
|
||||||
//! synchronously via `upsert` whenever the LSP backend re-parses on
|
|
||||||
//! `didOpen` / `didChange`.
|
|
||||||
//!
|
|
||||||
//! The index keeps only the projections nav needs (headings + outgoing
|
|
||||||
//! links) — the full AST already lives in the backend's `documents`
|
|
||||||
//! `DashMap`.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use nuwiki_core::ast::{
|
|
||||||
BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, LinkTarget, ListNode, Span,
|
|
||||||
TableNode, TagScope,
|
|
||||||
};
|
|
||||||
use nuwiki_core::date::DiaryDate;
|
|
||||||
use tower_lsp::lsp_types::Url;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct IndexedPage {
|
|
||||||
pub uri: Url,
|
|
||||||
/// Page name relative to the workspace root, without the `.wiki`
|
|
||||||
/// extension. Falls back to the URL's last path segment if no root.
|
|
||||||
pub name: String,
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub headings: Vec<HeadingInfo>,
|
|
||||||
pub outgoing: Vec<OutgoingLink>,
|
|
||||||
/// Every `TagNode` on the page. `tags_by_name` on the
|
|
||||||
/// containing `WorkspaceIndex` is the reverse map.
|
|
||||||
pub tags: Vec<TagInfo>,
|
|
||||||
/// `Some(date)` when this page is a *daily* diary entry —
|
|
||||||
/// stem parses as `YYYY-MM-DD`. Equivalent to
|
|
||||||
/// `diary_period.and_then(|p| if let DiaryPeriod::Day(d) = p { Some(d) } else { None })`.
|
|
||||||
/// Kept as its own field for back-compat with prev/next/list callers
|
|
||||||
/// that pre-date the multi-frequency upgrade.
|
|
||||||
pub diary_date: Option<DiaryDate>,
|
|
||||||
/// Cluster 4 (vimwiki parity): `Some(period)` for any diary entry
|
|
||||||
/// (daily, weekly, monthly, yearly). The stem must match the
|
|
||||||
/// canonical format for one of the four frequencies.
|
|
||||||
pub diary_period: Option<nuwiki_core::date::DiaryPeriod>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One tag occurrence on a page. `name` is the bare tag string (no
|
|
||||||
/// surrounding colons). Multiple tags on the same source line each get
|
|
||||||
/// their own `TagInfo` so anchor lookup can return a precise location.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TagInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub scope: TagScope,
|
|
||||||
pub span: Span,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct HeadingInfo {
|
|
||||||
pub title: String,
|
|
||||||
pub level: u8,
|
|
||||||
pub anchor: String,
|
|
||||||
pub span: Span,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IndexedPage {
|
|
||||||
/// Find a heading whose anchor matches `request`. The on-disk wikilink
|
|
||||||
/// syntax lets users write either the slug (`[[Page#some-heading]]`) or
|
|
||||||
/// the raw heading text (`[[Page#Some Heading]]`). `slugify` is
|
|
||||||
/// idempotent on valid slug input, so applying it to the request
|
|
||||||
/// before comparison handles both forms in a single pass.
|
|
||||||
pub fn find_heading_by_anchor(&self, request: &str) -> Option<&HeadingInfo> {
|
|
||||||
let needle = slugify(request);
|
|
||||||
self.headings.iter().find(|h| h.anchor == needle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct OutgoingLink {
|
|
||||||
/// Resolved target page name (for `LinkKind::Wiki`) or `None` for kinds
|
|
||||||
/// that don't address another page in the workspace (`AnchorOnly`,
|
|
||||||
/// `Raw`, `File`, `Local`, `Diary`, …).
|
|
||||||
pub target_page: Option<String>,
|
|
||||||
pub anchor: Option<String>,
|
|
||||||
pub kind: LinkKind,
|
|
||||||
pub span: Span,
|
|
||||||
/// Mirrors `LinkTarget::is_absolute` so `classify_outgoing` and the
|
|
||||||
/// `links` query can distinguish `[[Page]]` (source-relative) from
|
|
||||||
/// `[[/Page]]` (root-relative) without re-parsing.
|
|
||||||
pub is_absolute: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Backlink {
|
|
||||||
pub source_uri: Url,
|
|
||||||
pub source_span: Span,
|
|
||||||
pub target_anchor: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A tag's location, used by `WorkspaceIndex::tags_for` for cross-page
|
|
||||||
/// `[[Page#tag]]` resolution and `nuwiki.tags.search`.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TagOccurrence {
|
|
||||||
pub uri: Url,
|
|
||||||
pub span: Span,
|
|
||||||
pub scope: TagScope,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct WorkspaceIndex {
|
|
||||||
pub root: Option<PathBuf>,
|
|
||||||
/// Subdir relative to `root` that holds diary entries.
|
|
||||||
/// Mirrors `WikiConfig::diary_rel_path`; stored here so `upsert` can
|
|
||||||
/// classify each indexed URI without a back-reference to the config.
|
|
||||||
pub diary_rel_path: Option<String>,
|
|
||||||
/// File extension for this wiki's pages (e.g. `".wiki"` or `"wiki"`).
|
|
||||||
/// Used to normalise link targets that include the extension so that
|
|
||||||
/// `[[page.wiki]]` resolves the same way as `[[page]]`.
|
|
||||||
pub file_extension: Option<String>,
|
|
||||||
pub pages_by_uri: HashMap<Url, IndexedPage>,
|
|
||||||
pub pages_by_name: HashMap<String, Url>,
|
|
||||||
pub backlinks: HashMap<String, Vec<Backlink>>,
|
|
||||||
/// Tag name → every occurrence across the workspace. Used by
|
|
||||||
/// the nav handlers (tag-as-anchor `definition`, `workspace/symbol`
|
|
||||||
/// inclusion) and the `nuwiki.tags.search` command.
|
|
||||||
pub tags_by_name: HashMap<String, Vec<TagOccurrence>>,
|
|
||||||
/// For each source URI, the backlink-bucket keys and tag-bucket names it
|
|
||||||
/// contributed. Lets `remove` touch only the affected buckets instead of
|
|
||||||
/// scanning every bucket in the workspace (O(touched), not O(total)).
|
|
||||||
contributions: HashMap<Url, SourceContributions>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The bucket keys a single source page wrote into, so they can be undone
|
|
||||||
/// in `remove` without a full scan. See [`WorkspaceIndex::contributions`].
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct SourceContributions {
|
|
||||||
backlink_keys: Vec<String>,
|
|
||||||
tag_names: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorkspaceIndex {
|
|
||||||
pub fn new(root: Option<PathBuf>) -> Self {
|
|
||||||
Self {
|
|
||||||
root,
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_diary_rel_path(mut self, rel: Option<String>) -> Self {
|
|
||||||
self.diary_rel_path = rel;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_file_extension(mut self, ext: Option<String>) -> Self {
|
|
||||||
self.file_extension = ext;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip the wiki's configured file extension from a link target path.
|
|
||||||
/// Handles both `"page.wiki"` → `"page"` and `"subdir/page.wiki"` →
|
|
||||||
/// `"subdir/page"`. Returns `path` unchanged when the extension doesn't
|
|
||||||
/// match or no extension is configured.
|
|
||||||
pub fn strip_link_extension<'a>(&self, path: &'a str) -> &'a str {
|
|
||||||
strip_wiki_extension(path, self.file_extension.as_deref())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert or update an indexed page. Removes any prior indexing for
|
|
||||||
/// the same URI first (handles renames and re-parses without leaking
|
|
||||||
/// stale backlinks).
|
|
||||||
pub fn upsert(&mut self, uri: Url, ast: &DocumentNode) {
|
|
||||||
self.remove(&uri);
|
|
||||||
let name = page_name_from_uri(&uri, self.root.as_deref());
|
|
||||||
let diary_period =
|
|
||||||
diary_period_for_uri(&uri, self.root.as_deref(), self.diary_rel_path.as_deref());
|
|
||||||
let diary_date = diary_period.and_then(|p| match p {
|
|
||||||
nuwiki_core::date::DiaryPeriod::Day(d) => Some(d),
|
|
||||||
_ => None,
|
|
||||||
});
|
|
||||||
let mut page = IndexedPage {
|
|
||||||
uri: uri.clone(),
|
|
||||||
name: name.clone(),
|
|
||||||
title: ast.metadata.title.clone(),
|
|
||||||
headings: Vec::new(),
|
|
||||||
outgoing: Vec::new(),
|
|
||||||
tags: Vec::new(),
|
|
||||||
diary_date,
|
|
||||||
diary_period,
|
|
||||||
};
|
|
||||||
index_blocks(&ast.children, &mut page);
|
|
||||||
|
|
||||||
let mut contrib = SourceContributions::default();
|
|
||||||
let ext = self.file_extension.as_deref();
|
|
||||||
for link in &page.outgoing {
|
|
||||||
if let Some(tgt) = &link.target_page {
|
|
||||||
let key = canonical_link_name(tgt, &name, link.is_absolute, ext);
|
|
||||||
self.backlinks
|
|
||||||
.entry(key.clone())
|
|
||||||
.or_default()
|
|
||||||
.push(Backlink {
|
|
||||||
source_uri: uri.clone(),
|
|
||||||
source_span: link.span,
|
|
||||||
target_anchor: link.anchor.clone(),
|
|
||||||
});
|
|
||||||
contrib.backlink_keys.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maintain the reverse tag map alongside the per-page list.
|
|
||||||
for tag in &page.tags {
|
|
||||||
self.tags_by_name
|
|
||||||
.entry(tag.name.clone())
|
|
||||||
.or_default()
|
|
||||||
.push(TagOccurrence {
|
|
||||||
uri: uri.clone(),
|
|
||||||
span: tag.span,
|
|
||||||
scope: tag.scope,
|
|
||||||
});
|
|
||||||
contrib.tag_names.push(tag.name.clone());
|
|
||||||
}
|
|
||||||
self.contributions.insert(uri.clone(), contrib);
|
|
||||||
|
|
||||||
self.pages_by_name.insert(name, uri.clone());
|
|
||||||
self.pages_by_uri.insert(uri, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove(&mut self, uri: &Url) {
|
|
||||||
if let Some(page) = self.pages_by_uri.remove(uri) {
|
|
||||||
self.pages_by_name.remove(&page.name);
|
|
||||||
}
|
|
||||||
// Only revisit the buckets this source actually wrote into, instead
|
|
||||||
// of scanning every backlink/tag bucket in the workspace.
|
|
||||||
if let Some(contrib) = self.contributions.remove(uri) {
|
|
||||||
for key in contrib.backlink_keys {
|
|
||||||
if let Some(entries) = self.backlinks.get_mut(&key) {
|
|
||||||
entries.retain(|b| &b.source_uri != uri);
|
|
||||||
if entries.is_empty() {
|
|
||||||
self.backlinks.remove(&key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for name in contrib.tag_names {
|
|
||||||
if let Some(entries) = self.tags_by_name.get_mut(&name) {
|
|
||||||
entries.retain(|t| &t.uri != uri);
|
|
||||||
if entries.is_empty() {
|
|
||||||
self.tags_by_name.remove(&name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All occurrences of a tag across the workspace. Empty slice when the
|
|
||||||
/// tag isn't indexed.
|
|
||||||
pub fn tags_for(&self, name: &str) -> &[TagOccurrence] {
|
|
||||||
self.tags_by_name
|
|
||||||
.get(name)
|
|
||||||
.map(Vec::as_slice)
|
|
||||||
.unwrap_or(&[])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve a `LinkTarget` to a page URI in this workspace.
|
|
||||||
/// `source_page` is the name of the page the link originates from —
|
|
||||||
/// needed for `AnchorOnly` resolution and source-relative `Wiki` links.
|
|
||||||
pub fn resolve(&self, target: &LinkTarget, source_page: &str) -> Option<&Url> {
|
|
||||||
match target.kind {
|
|
||||||
LinkKind::Wiki => target
|
|
||||||
.path
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|p| self.resolve_wiki_path(p, source_page, target.is_absolute)),
|
|
||||||
LinkKind::AnchorOnly => self.pages_by_name.get(source_page),
|
|
||||||
// Interwiki / Diary / File / Local / Raw aren't resolved
|
|
||||||
// against the workspace index here. The handlers fall back to
|
|
||||||
// best-effort resolution (e.g. building file:// URLs).
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Look up a wiki link target by name. Tries source-relative first
|
|
||||||
/// (vimwiki's default — `[[Page]]` in `posts/index.wiki` opens
|
|
||||||
/// `posts/Page.wiki`) then falls back to root-relative. Skips the
|
|
||||||
/// source-relative attempt for explicitly absolute (`[[/Page]]`) and
|
|
||||||
/// for source pages already at the wiki root (`source_page` with no
|
|
||||||
/// `/`). Strips the configured `.wiki` extension and collapses any
|
|
||||||
/// `.`/`..` segments before lookup.
|
|
||||||
pub fn resolve_wiki_path(
|
|
||||||
&self,
|
|
||||||
path: &str,
|
|
||||||
source_page: &str,
|
|
||||||
is_absolute: bool,
|
|
||||||
) -> Option<&Url> {
|
|
||||||
let normalized = self.strip_link_extension(path);
|
|
||||||
if !is_absolute {
|
|
||||||
if let Some(slash) = source_page.rfind('/') {
|
|
||||||
let candidate = collapse_dots(&format!("{}/{}", &source_page[..slash], normalized));
|
|
||||||
if let Some(uri) = self.pages_by_name.get(&candidate) {
|
|
||||||
return Some(uri);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let root_candidate = collapse_dots(normalized);
|
|
||||||
self.pages_by_name.get(&root_candidate)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn page(&self, uri: &Url) -> Option<&IndexedPage> {
|
|
||||||
self.pages_by_uri.get(uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn page_by_name(&self, name: &str) -> Option<&IndexedPage> {
|
|
||||||
self.pages_by_name
|
|
||||||
.get(name)
|
|
||||||
.and_then(|u| self.pages_by_uri.get(u))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve a wiki link target to the page it points at. Source-relative
|
|
||||||
/// first, then root-relative — mirrors [`Self::resolve_wiki_path`] but
|
|
||||||
/// returns the `IndexedPage` so anchor checks have everything they need.
|
|
||||||
pub fn page_for_wiki_target(
|
|
||||||
&self,
|
|
||||||
path: &str,
|
|
||||||
source_page: &str,
|
|
||||||
is_absolute: bool,
|
|
||||||
) -> Option<&IndexedPage> {
|
|
||||||
self.resolve_wiki_path(path, source_page, is_absolute)
|
|
||||||
.and_then(|u| self.pages_by_uri.get(u))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn page_names(&self) -> Vec<String> {
|
|
||||||
let mut v: Vec<String> = self.pages_by_name.keys().cloned().collect();
|
|
||||||
v.sort();
|
|
||||||
v
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn backlinks_for(&self, page_name: &str) -> &[Backlink] {
|
|
||||||
self.backlinks
|
|
||||||
.get(page_name)
|
|
||||||
.map(Vec::as_slice)
|
|
||||||
.unwrap_or(&[])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a parsed [`DiaryPeriod`] when `uri` is a diary entry at any of
|
|
||||||
/// the four supported frequencies. Recognises:
|
|
||||||
///
|
|
||||||
/// `YYYY-MM-DD` → Day
|
|
||||||
/// `YYYY-Www` → Week (ISO)
|
|
||||||
/// `YYYY-MM` → Month
|
|
||||||
/// `YYYY` → Year
|
|
||||||
///
|
|
||||||
/// The path must sit under `<root>/<diary_rel_path>/` to be accepted;
|
|
||||||
/// without a `root` we accept any stem (so ad-hoc test setups still work).
|
|
||||||
pub fn diary_period_for_uri(
|
|
||||||
uri: &Url,
|
|
||||||
root: Option<&Path>,
|
|
||||||
diary_rel_path: Option<&str>,
|
|
||||||
) -> Option<nuwiki_core::date::DiaryPeriod> {
|
|
||||||
let path = uri.to_file_path().ok()?;
|
|
||||||
let stem = path.file_stem()?.to_str()?;
|
|
||||||
let parsed = nuwiki_core::date::DiaryPeriod::parse(stem)?;
|
|
||||||
let Some(root) = root else {
|
|
||||||
return Some(parsed);
|
|
||||||
};
|
|
||||||
let parent = path.parent()?;
|
|
||||||
let expected = root.join(diary_rel_path.unwrap_or("diary"));
|
|
||||||
if parent.starts_with(&expected) {
|
|
||||||
Some(parsed)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Derive a page name from a `file://` URL. With a workspace root the name
|
|
||||||
/// is the URL's path relative to root, sans `.wiki`. Without a root it's
|
|
||||||
/// just the filename stem.
|
|
||||||
pub fn page_name_from_uri(uri: &Url, root: Option<&Path>) -> String {
|
|
||||||
if let (Some(root), Ok(p)) = (root, uri.to_file_path()) {
|
|
||||||
if let Ok(rel) = p.strip_prefix(root) {
|
|
||||||
let stripped = rel.with_extension("");
|
|
||||||
return stripped
|
|
||||||
.to_string_lossy()
|
|
||||||
.replace(std::path::MAIN_SEPARATOR, "/");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: last path segment, strip .wiki if present.
|
|
||||||
if let Ok(p) = uri.to_file_path() {
|
|
||||||
return p
|
|
||||||
.file_stem()
|
|
||||||
.map(|s| s.to_string_lossy().into_owned())
|
|
||||||
.unwrap_or_default();
|
|
||||||
}
|
|
||||||
uri.path_segments()
|
|
||||||
.and_then(|mut s| s.next_back())
|
|
||||||
.map(|s| {
|
|
||||||
s.strip_suffix(".wiki")
|
|
||||||
.unwrap_or(s)
|
|
||||||
.trim_start_matches('/')
|
|
||||||
.to_string()
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalise heading text into a URL-safe anchor slug.
|
|
||||||
pub fn slugify(s: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(s.len());
|
|
||||||
let mut prev_dash = false;
|
|
||||||
for ch in s.chars() {
|
|
||||||
if ch.is_alphanumeric() {
|
|
||||||
for c in ch.to_lowercase() {
|
|
||||||
out.push(c);
|
|
||||||
}
|
|
||||||
prev_dash = false;
|
|
||||||
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !prev_dash && !out.is_empty() {
|
|
||||||
out.push('-');
|
|
||||||
prev_dash = true;
|
|
||||||
}
|
|
||||||
// other punctuation: drop
|
|
||||||
}
|
|
||||||
while out.ends_with('-') {
|
|
||||||
out.pop();
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Indexing helpers =====
|
|
||||||
|
|
||||||
fn index_blocks(blocks: &[BlockNode], page: &mut IndexedPage) {
|
|
||||||
for block in blocks {
|
|
||||||
index_block(block, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn index_block(block: &BlockNode, page: &mut IndexedPage) {
|
|
||||||
match block {
|
|
||||||
BlockNode::Heading(h) => {
|
|
||||||
let title = inline_to_text(&h.children);
|
|
||||||
page.headings.push(HeadingInfo {
|
|
||||||
title: title.clone(),
|
|
||||||
level: h.level,
|
|
||||||
anchor: slugify(&title),
|
|
||||||
span: h.span,
|
|
||||||
});
|
|
||||||
index_inlines(&h.children, page);
|
|
||||||
}
|
|
||||||
BlockNode::Paragraph(p) => index_inlines(&p.children, page),
|
|
||||||
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
|
||||||
for c in children {
|
|
||||||
index_block(c, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BlockNode::List(ListNode { items, .. }) => {
|
|
||||||
for item in items {
|
|
||||||
index_inlines(&item.children, page);
|
|
||||||
if let Some(sub) = &item.sublist {
|
|
||||||
index_block(&BlockNode::List(sub.clone()), page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BlockNode::DefinitionList(dl) => {
|
|
||||||
for item in &dl.items {
|
|
||||||
if let Some(term) = &item.term {
|
|
||||||
index_inlines(term, page);
|
|
||||||
}
|
|
||||||
for def in &item.definitions {
|
|
||||||
index_inlines(def, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BlockNode::Table(TableNode { rows, .. }) => {
|
|
||||||
for row in rows {
|
|
||||||
for cell in &row.cells {
|
|
||||||
index_inlines(&cell.children, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BlockNode::Tag(t) => {
|
|
||||||
for name in &t.tags {
|
|
||||||
page.tags.push(TagInfo {
|
|
||||||
name: name.clone(),
|
|
||||||
scope: t.scope,
|
|
||||||
span: t.span,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn index_inlines(inlines: &[InlineNode], page: &mut IndexedPage) {
|
|
||||||
for n in inlines {
|
|
||||||
match n {
|
|
||||||
InlineNode::WikiLink(w) => {
|
|
||||||
page.outgoing.push(OutgoingLink {
|
|
||||||
target_page: w.target.path.clone(),
|
|
||||||
anchor: w.target.anchor.clone(),
|
|
||||||
kind: w.target.kind,
|
|
||||||
span: w.span,
|
|
||||||
is_absolute: w.target.is_absolute,
|
|
||||||
});
|
|
||||||
if let Some(desc) = &w.description {
|
|
||||||
index_inlines(desc, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
InlineNode::ExternalLink(e) => {
|
|
||||||
if let Some(desc) = &e.description {
|
|
||||||
index_inlines(desc, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
InlineNode::Bold(b) => index_inlines(&b.children, page),
|
|
||||||
InlineNode::Italic(i) => index_inlines(&i.children, page),
|
|
||||||
InlineNode::BoldItalic(bi) => index_inlines(&bi.children, page),
|
|
||||||
InlineNode::Strikethrough(s) => index_inlines(&s.children, page),
|
|
||||||
InlineNode::Superscript(s) => index_inlines(&s.children, page),
|
|
||||||
InlineNode::Subscript(s) => index_inlines(&s.children, page),
|
|
||||||
InlineNode::Color(c) => index_inlines(&c.children, page),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inline_to_text(inlines: &[InlineNode]) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
inline_to_text_into(inlines, &mut out);
|
|
||||||
out.trim().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inline_to_text_into(inlines: &[InlineNode], out: &mut String) {
|
|
||||||
for n in inlines {
|
|
||||||
match n {
|
|
||||||
InlineNode::Text(t) => out.push_str(&t.content),
|
|
||||||
InlineNode::Bold(b) => inline_to_text_into(&b.children, out),
|
|
||||||
InlineNode::Italic(i) => inline_to_text_into(&i.children, out),
|
|
||||||
InlineNode::BoldItalic(bi) => inline_to_text_into(&bi.children, out),
|
|
||||||
InlineNode::Strikethrough(s) => inline_to_text_into(&s.children, out),
|
|
||||||
InlineNode::Code(c) => out.push_str(&c.content),
|
|
||||||
InlineNode::Superscript(s) => inline_to_text_into(&s.children, out),
|
|
||||||
InlineNode::Subscript(s) => inline_to_text_into(&s.children, out),
|
|
||||||
InlineNode::MathInline(m) => out.push_str(&m.content),
|
|
||||||
InlineNode::Color(c) => inline_to_text_into(&c.children, out),
|
|
||||||
InlineNode::WikiLink(w) => {
|
|
||||||
if let Some(d) = &w.description {
|
|
||||||
inline_to_text_into(d, out);
|
|
||||||
} else if let Some(p) = &w.target.path {
|
|
||||||
out.push_str(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
InlineNode::ExternalLink(e) => match &e.description {
|
|
||||||
Some(d) => inline_to_text_into(d, out),
|
|
||||||
None => out.push_str(&e.url),
|
|
||||||
},
|
|
||||||
InlineNode::Keyword(_) => {}
|
|
||||||
InlineNode::Transclusion(t) => out.push_str(t.alt.as_deref().unwrap_or(&t.url)),
|
|
||||||
InlineNode::RawUrl(r) => out.push_str(&r.url),
|
|
||||||
InlineNode::SoftBreak(_) => out.push(' '),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Filesystem walking =====
|
|
||||||
|
|
||||||
/// Canonicalise a wikilink target into the page name the workspace index
|
|
||||||
/// would key it by. Mirrors [`WorkspaceIndex::resolve_wiki_path`]'s preference
|
|
||||||
/// (source-relative when not explicitly absolute, root-relative otherwise) but
|
|
||||||
/// without requiring the target to already be indexed — useful for backlinks
|
|
||||||
/// where the target may be added to the index after the source.
|
|
||||||
///
|
|
||||||
/// Collapses `.` and `..` segments so `posts/../foo` → `foo`. `..` at the
|
|
||||||
/// root of the wiki is treated as a no-op (the wiki has no parent in this
|
|
||||||
/// model — interwiki crossings use the dedicated `[[wiki:Page]]` syntax).
|
|
||||||
pub fn canonical_link_name(
|
|
||||||
path: &str,
|
|
||||||
source_page: &str,
|
|
||||||
is_absolute: bool,
|
|
||||||
file_extension: Option<&str>,
|
|
||||||
) -> String {
|
|
||||||
let normalized = strip_wiki_extension(path, file_extension);
|
|
||||||
let joined = if !is_absolute {
|
|
||||||
if let Some(slash) = source_page.rfind('/') {
|
|
||||||
format!("{}/{}", &source_page[..slash], normalized)
|
|
||||||
} else {
|
|
||||||
normalized.to_string()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
normalized.to_string()
|
|
||||||
};
|
|
||||||
collapse_dots(&joined)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Collapse `.` and `..` segments in a `/`-separated path string. Doesn't
|
|
||||||
/// touch URI escapes — these are workspace-relative page names, not URLs.
|
|
||||||
pub fn collapse_dots(s: &str) -> String {
|
|
||||||
let mut parts: Vec<&str> = Vec::new();
|
|
||||||
for seg in s.split('/') {
|
|
||||||
match seg {
|
|
||||||
"" | "." => continue,
|
|
||||||
".." => {
|
|
||||||
parts.pop();
|
|
||||||
}
|
|
||||||
_ => parts.push(seg),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parts.join("/")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip a wiki file extension from a link target path without allocating.
|
|
||||||
///
|
|
||||||
/// `file_extension` may be `".wiki"` (with leading dot) or `"wiki"` (without).
|
|
||||||
/// Returns `path` unchanged when no extension is configured, the extension is
|
|
||||||
/// empty, or the path doesn't end with `.{ext}`.
|
|
||||||
pub fn strip_wiki_extension<'a>(path: &'a str, file_extension: Option<&str>) -> &'a str {
|
|
||||||
let ext = match file_extension {
|
|
||||||
Some(e) => e.trim_start_matches('.'),
|
|
||||||
None => return path,
|
|
||||||
};
|
|
||||||
if ext.is_empty() {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
let dot_ext_len = ext.len() + 1; // +1 for the '.'
|
|
||||||
if path.len() > dot_ext_len
|
|
||||||
&& path.ends_with(ext)
|
|
||||||
&& path.as_bytes()[path.len() - dot_ext_len] == b'.'
|
|
||||||
{
|
|
||||||
&path[..path.len() - dot_ext_len]
|
|
||||||
} else {
|
|
||||||
path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recursively collect every `.wiki` file under `root`, skipping dotfiles
|
|
||||||
/// and dot-directories.
|
|
||||||
pub fn walk_wiki_files(root: &Path) -> Vec<PathBuf> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let mut stack = vec![root.to_path_buf()];
|
|
||||||
while let Some(dir) = stack.pop() {
|
|
||||||
let entries = match std::fs::read_dir(&dir) {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
let Some(name) = path.file_name() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if name.to_string_lossy().starts_with('.') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(meta) = entry.metadata() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if meta.is_dir() {
|
|
||||||
stack.push(path);
|
|
||||||
} else if meta.is_file() && path.extension().and_then(|e| e.to_str()) == Some("wiki") {
|
|
||||||
out.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.sort();
|
|
||||||
out
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,126 +0,0 @@
|
|||||||
//! Helpers shared by the navigation handlers (definition,
|
|
||||||
//! references, hover, completion).
|
|
||||||
//!
|
|
||||||
//! Two responsibilities:
|
|
||||||
//!
|
|
||||||
//! 1. **Position conversion** — clients send LSP `Position`s in either
|
|
||||||
//! UTF-8 or UTF-16 encoding; nuwiki AST spans are byte offsets.
|
|
||||||
//! `lsp_to_byte_pos` translates the LSP position into the same shape
|
|
||||||
//! so it can be compared against AST spans.
|
|
||||||
//! 2. **Position lookup** — given a byte position, find the most specific
|
|
||||||
//! `InlineNode` whose span covers it. Used to identify the link under
|
|
||||||
//! the cursor.
|
|
||||||
|
|
||||||
use nuwiki_core::ast::{BlockNode, DocumentNode, InlineNode, ListItemNode, Span};
|
|
||||||
use tower_lsp::lsp_types::Position as LspPosition;
|
|
||||||
|
|
||||||
use crate::semantic_tokens::LineIndex;
|
|
||||||
|
|
||||||
/// Translate an LSP `Position` (whose `character` is a UTF-16 code-unit
|
|
||||||
/// offset by default, or a UTF-8 byte offset when negotiated) into our
|
|
||||||
/// internal `(line, byte_col)` shape.
|
|
||||||
pub fn lsp_to_byte_pos(p: LspPosition, text: &str, utf8: bool) -> (u32, u32) {
|
|
||||||
if utf8 {
|
|
||||||
return (p.line, p.character);
|
|
||||||
}
|
|
||||||
let idx = LineIndex::new(text);
|
|
||||||
let line_str = idx.line_str(p.line, text);
|
|
||||||
let byte_col = utf16_to_byte_col(line_str, p.character);
|
|
||||||
(p.line, byte_col)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn utf16_to_byte_col(line: &str, target_utf16: u32) -> u32 {
|
|
||||||
let mut byte = 0u32;
|
|
||||||
let mut units = 0u32;
|
|
||||||
for ch in line.chars() {
|
|
||||||
if units >= target_utf16 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
units += ch.len_utf16() as u32;
|
|
||||||
byte += ch.len_utf8() as u32;
|
|
||||||
}
|
|
||||||
byte
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the most specific inline node whose span covers the given byte
|
|
||||||
/// position. Walks block structure first, then descends into inline
|
|
||||||
/// containers (bold, italic, …) until no child claims the position.
|
|
||||||
pub fn find_inline_at(doc: &DocumentNode, line: u32, byte_col: u32) -> Option<&InlineNode> {
|
|
||||||
for block in &doc.children {
|
|
||||||
if let Some(node) = find_in_block(block, line, byte_col) {
|
|
||||||
return Some(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_in_block(block: &BlockNode, line: u32, col: u32) -> Option<&InlineNode> {
|
|
||||||
match block {
|
|
||||||
BlockNode::Heading(h) => find_in_inlines(&h.children, line, col),
|
|
||||||
BlockNode::Paragraph(p) => find_in_inlines(&p.children, line, col),
|
|
||||||
BlockNode::Blockquote(b) => b.children.iter().find_map(|c| find_in_block(c, line, col)),
|
|
||||||
BlockNode::List(l) => l
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.find_map(|it| find_in_list_item(it, line, col)),
|
|
||||||
BlockNode::DefinitionList(dl) => dl.items.iter().find_map(|it| {
|
|
||||||
it.term
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|t| find_in_inlines(t, line, col))
|
|
||||||
.or_else(|| {
|
|
||||||
it.definitions
|
|
||||||
.iter()
|
|
||||||
.find_map(|d| find_in_inlines(d, line, col))
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
BlockNode::Table(t) => t.rows.iter().find_map(|r| {
|
|
||||||
r.cells
|
|
||||||
.iter()
|
|
||||||
.find_map(|c| find_in_inlines(&c.children, line, col))
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_in_list_item(item: &ListItemNode, line: u32, col: u32) -> Option<&InlineNode> {
|
|
||||||
find_in_inlines(&item.children, line, col).or_else(|| {
|
|
||||||
item.sublist.as_ref().and_then(|sub| {
|
|
||||||
sub.items
|
|
||||||
.iter()
|
|
||||||
.find_map(|i| find_in_list_item(i, line, col))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_in_inlines(inlines: &[InlineNode], line: u32, col: u32) -> Option<&InlineNode> {
|
|
||||||
for n in inlines {
|
|
||||||
if !span_contains(n.span(), line, col) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Descend into inline containers so we return the *innermost* hit.
|
|
||||||
let deeper = match n {
|
|
||||||
InlineNode::Bold(b) => find_in_inlines(&b.children, line, col),
|
|
||||||
InlineNode::Italic(i) => find_in_inlines(&i.children, line, col),
|
|
||||||
InlineNode::BoldItalic(bi) => find_in_inlines(&bi.children, line, col),
|
|
||||||
InlineNode::Strikethrough(s) => find_in_inlines(&s.children, line, col),
|
|
||||||
InlineNode::Superscript(s) => find_in_inlines(&s.children, line, col),
|
|
||||||
InlineNode::Subscript(s) => find_in_inlines(&s.children, line, col),
|
|
||||||
InlineNode::Color(c) => find_in_inlines(&c.children, line, col),
|
|
||||||
// Links are an atomic unit for navigation: a cursor anywhere in
|
|
||||||
// `[[target|description]]` (or `[desc](url)`) should resolve the
|
|
||||||
// link itself. Don't descend into the description — returning its
|
|
||||||
// inner Text node leaves goto-definition/references/hover with
|
|
||||||
// nothing to act on ("No locations found" when on the title).
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
return Some(deeper.unwrap_or(n));
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn span_contains(s: Span, line: u32, col: u32) -> bool {
|
|
||||||
let start = (s.start.line, s.start.column);
|
|
||||||
let end = (s.end.line, s.end.column);
|
|
||||||
let p = (line, col);
|
|
||||||
p >= start && p < end
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
//! `textDocument/rename` — server-side rename with cross-document link
|
|
||||||
//! rewriting.
|
|
||||||
//!
|
|
||||||
//! Implementation:
|
|
||||||
//! 1. Resolve the rename target — cursor on a wikilink renames the linked
|
|
||||||
//! page; otherwise rename the current file.
|
|
||||||
//! 2. Build the new URI from the wiki root + the user-supplied path.
|
|
||||||
//! 3. For every page in the wiki's index with an outgoing link to the
|
|
||||||
//! old page name, read its source via the closed-doc loader
|
|
||||||
//! so cached spans are validated against current text, then emit a
|
|
||||||
//! `TextEdit` that swaps the `[[old]]` for `[[new]]` while preserving
|
|
||||||
//! anchors and descriptions.
|
|
||||||
//! 4. Add the `RenameFile` op last so the builder emits it before content
|
|
||||||
//! edits in `documentChanges` ordering.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use tower_lsp::lsp_types::{Url, WorkspaceEdit};
|
|
||||||
|
|
||||||
use crate::edits::{op_rename, text_edit_replace, WorkspaceEditBuilder};
|
|
||||||
use crate::index::page_name_from_uri;
|
|
||||||
use crate::Backend;
|
|
||||||
|
|
||||||
/// Compute the `WorkspaceEdit` for renaming `target_uri` to `new_name`.
|
|
||||||
///
|
|
||||||
/// Returns `None` when:
|
|
||||||
/// - the URI doesn't belong to any registered wiki,
|
|
||||||
/// - the new URI couldn't be built from the wiki root + new_name,
|
|
||||||
/// - the new URI is the same as the old one.
|
|
||||||
pub(crate) async fn compute_rename(
|
|
||||||
backend: &Backend,
|
|
||||||
target_uri: Url,
|
|
||||||
new_name: String,
|
|
||||||
utf8: bool,
|
|
||||||
) -> Option<WorkspaceEdit> {
|
|
||||||
let wiki = backend.wiki_for_uri(&target_uri)?;
|
|
||||||
let old_name = page_name_from_uri(&target_uri, Some(&wiki.config.root));
|
|
||||||
// Spaces in the link *target* (and the file on disk) are replaced with
|
|
||||||
// the wiki's `links_space_char`; descriptions keep their original text.
|
|
||||||
let new_name = apply_links_space_char(&new_name, &wiki.config.links_space_char);
|
|
||||||
let new_uri = build_new_uri(&wiki.config.root, &new_name, &wiki.config.file_extension)?;
|
|
||||||
if new_uri == target_uri {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut builder = WorkspaceEditBuilder::new();
|
|
||||||
builder.file_op(op_rename(target_uri.clone(), new_uri));
|
|
||||||
|
|
||||||
// Snapshot the backlinks so the index read lock is released before we
|
|
||||||
// await any disk I/O.
|
|
||||||
let backlinks = {
|
|
||||||
let idx = wiki.index.read().ok()?;
|
|
||||||
idx.backlinks_for(&old_name).to_vec()
|
|
||||||
};
|
|
||||||
|
|
||||||
for bl in backlinks {
|
|
||||||
let Ok(snapshot) = backend.read_or_open(&bl.source_uri).await else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let start = bl.source_span.start.offset.min(snapshot.text.len());
|
|
||||||
let end = bl.source_span.end.offset.min(snapshot.text.len());
|
|
||||||
if start >= end {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let original = &snapshot.text[start..end];
|
|
||||||
if let Some(new_link) = rewrite_wikilink_target(original, &new_name) {
|
|
||||||
let edit = text_edit_replace(bl.source_span, new_link, &snapshot.text, utf8);
|
|
||||||
builder.edit(bl.source_uri.clone(), edit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if builder.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(builder.build())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace spaces in a link path with the configured `links_space_char`.
|
|
||||||
/// The default `" "` is an identity transform (spaces kept verbatim).
|
|
||||||
pub fn apply_links_space_char(name: &str, space_char: &str) -> String {
|
|
||||||
if space_char == " " {
|
|
||||||
name.to_string()
|
|
||||||
} else {
|
|
||||||
name.replace(' ', space_char)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build `<root>/<new_name>(<ext>)` as a `file://` URL.
|
|
||||||
pub fn build_new_uri(root: &Path, new_name: &str, ext: &str) -> Option<Url> {
|
|
||||||
let mut path = root.to_path_buf();
|
|
||||||
for part in new_name.split(['/', '\\']) {
|
|
||||||
if !part.is_empty() {
|
|
||||||
path.push(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let path_str = path.to_string_lossy().to_string();
|
|
||||||
let with_ext = if path_str.ends_with(ext) {
|
|
||||||
path_str
|
|
||||||
} else {
|
|
||||||
format!("{path_str}{ext}")
|
|
||||||
};
|
|
||||||
Url::from_file_path(with_ext).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rewrite a `[[...]]` source segment so the path portion becomes
|
|
||||||
/// `new_path`, keeping any `#anchor` and `|description` parts intact.
|
|
||||||
///
|
|
||||||
/// Returns `None` if `segment` doesn't look like a wikilink (starts with
|
|
||||||
/// `[[`, ends with `]]`).
|
|
||||||
pub fn rewrite_wikilink_target(segment: &str, new_path: &str) -> Option<String> {
|
|
||||||
if !segment.starts_with("[[") || !segment.ends_with("]]") || segment.len() < 4 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let inner = &segment[2..segment.len() - 2];
|
|
||||||
let (path_part, description) = match inner.find('|') {
|
|
||||||
Some(i) => (&inner[..i], Some(&inner[i + 1..])),
|
|
||||||
None => (inner, None),
|
|
||||||
};
|
|
||||||
let anchor = path_part.find('#').map(|i| &path_part[i + 1..]);
|
|
||||||
|
|
||||||
let mut out = String::with_capacity(segment.len());
|
|
||||||
out.push_str("[[");
|
|
||||||
out.push_str(new_path);
|
|
||||||
if let Some(a) = anchor {
|
|
||||||
out.push('#');
|
|
||||||
out.push_str(a);
|
|
||||||
}
|
|
||||||
if let Some(d) = description {
|
|
||||||
out.push('|');
|
|
||||||
out.push_str(d);
|
|
||||||
}
|
|
||||||
out.push_str("]]");
|
|
||||||
Some(out)
|
|
||||||
}
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
//! Semantic-token emission for the vimwiki AST.
|
|
||||||
//!
|
|
||||||
//! Token-type scheme: custom `vimwiki*` types plus
|
|
||||||
//! `level1`..`level6` + `centered` modifiers.
|
|
||||||
//!
|
|
||||||
//! Pipeline:
|
|
||||||
//!
|
|
||||||
//! 1. Walk the AST, emitting one or more `RawToken`s per AST node. Block
|
|
||||||
//! structure that "wraps" text (paragraph, list item, table cell)
|
|
||||||
//! recurses into inlines instead of emitting a wrapping token, so we
|
|
||||||
//! end up with a non-overlapping tile of inline tokens. Block nodes
|
|
||||||
//! that are themselves visually distinct (heading, code block, math
|
|
||||||
//! block, comment) emit one token across their whole span.
|
|
||||||
//! 2. Multi-line spans are split into one token per line — the LSP wire
|
|
||||||
//! format can't express a single token that crosses a newline.
|
|
||||||
//! 3. Tokens are sorted by `(line, byte_col)` and encoded as the LSP
|
|
||||||
//! delta-quintuple stream `(deltaLine, deltaStart, length, type, mods)`.
|
|
||||||
//! When the client doesn't support UTF-8 encoding, byte offsets are
|
|
||||||
//! converted to UTF-16 code-unit counts on the fly.
|
|
||||||
|
|
||||||
use nuwiki_core::ast::{
|
|
||||||
BlockNode, BlockquoteNode, DocumentNode, HeadingNode, InlineNode, ListItemNode, ListNode, Span,
|
|
||||||
TableNode,
|
|
||||||
};
|
|
||||||
use tower_lsp::lsp_types::{Range, SemanticTokenModifier, SemanticTokenType, SemanticTokensLegend};
|
|
||||||
|
|
||||||
// ===== Legend =====
|
|
||||||
|
|
||||||
pub const TOKEN_HEADING: u32 = 0;
|
|
||||||
pub const TOKEN_BOLD: u32 = 1;
|
|
||||||
pub const TOKEN_ITALIC: u32 = 2;
|
|
||||||
pub const TOKEN_BOLD_ITALIC: u32 = 3;
|
|
||||||
pub const TOKEN_STRIKETHROUGH: u32 = 4;
|
|
||||||
pub const TOKEN_SUPERSCRIPT: u32 = 5;
|
|
||||||
pub const TOKEN_SUBSCRIPT: u32 = 6;
|
|
||||||
pub const TOKEN_CODE: u32 = 7;
|
|
||||||
pub const TOKEN_CODE_BLOCK: u32 = 8;
|
|
||||||
pub const TOKEN_MATH: u32 = 9;
|
|
||||||
pub const TOKEN_MATH_BLOCK: u32 = 10;
|
|
||||||
pub const TOKEN_KEYWORD: u32 = 11;
|
|
||||||
pub const TOKEN_COLOR: u32 = 12;
|
|
||||||
pub const TOKEN_WIKILINK: u32 = 13;
|
|
||||||
pub const TOKEN_EXTERNAL_LINK: u32 = 14;
|
|
||||||
pub const TOKEN_TRANSCLUSION: u32 = 15;
|
|
||||||
pub const TOKEN_URL: u32 = 16;
|
|
||||||
pub const TOKEN_COMMENT: u32 = 17;
|
|
||||||
pub const TOKEN_DEFINITION_TERM: u32 = 18;
|
|
||||||
pub const TOKEN_TAG: u32 = 19;
|
|
||||||
|
|
||||||
pub const TOKEN_TYPE_NAMES: &[&str] = &[
|
|
||||||
"vimwikiHeading",
|
|
||||||
"vimwikiBold",
|
|
||||||
"vimwikiItalic",
|
|
||||||
"vimwikiBoldItalic",
|
|
||||||
"vimwikiStrikethrough",
|
|
||||||
"vimwikiSuperscript",
|
|
||||||
"vimwikiSubscript",
|
|
||||||
"vimwikiCode",
|
|
||||||
"vimwikiCodeBlock",
|
|
||||||
"vimwikiMath",
|
|
||||||
"vimwikiMathBlock",
|
|
||||||
"vimwikiKeyword",
|
|
||||||
"vimwikiColor",
|
|
||||||
"vimwikiWikilink",
|
|
||||||
"vimwikiExternalLink",
|
|
||||||
"vimwikiTransclusion",
|
|
||||||
"vimwikiUrl",
|
|
||||||
"vimwikiComment",
|
|
||||||
"vimwikiDefinitionTerm",
|
|
||||||
"vimwikiTag",
|
|
||||||
];
|
|
||||||
|
|
||||||
pub const MOD_LEVEL1: u32 = 1 << 0;
|
|
||||||
pub const MOD_LEVEL2: u32 = 1 << 1;
|
|
||||||
pub const MOD_LEVEL3: u32 = 1 << 2;
|
|
||||||
pub const MOD_LEVEL4: u32 = 1 << 3;
|
|
||||||
pub const MOD_LEVEL5: u32 = 1 << 4;
|
|
||||||
pub const MOD_LEVEL6: u32 = 1 << 5;
|
|
||||||
pub const MOD_CENTERED: u32 = 1 << 6;
|
|
||||||
|
|
||||||
pub const TOKEN_MODIFIER_NAMES: &[&str] = &[
|
|
||||||
"level1", "level2", "level3", "level4", "level5", "level6", "centered",
|
|
||||||
];
|
|
||||||
|
|
||||||
pub fn legend() -> SemanticTokensLegend {
|
|
||||||
SemanticTokensLegend {
|
|
||||||
token_types: TOKEN_TYPE_NAMES
|
|
||||||
.iter()
|
|
||||||
.map(|s| SemanticTokenType::new(s))
|
|
||||||
.collect(),
|
|
||||||
token_modifiers: TOKEN_MODIFIER_NAMES
|
|
||||||
.iter()
|
|
||||||
.map(|s| SemanticTokenModifier::new(s))
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Raw tokens =====
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct RawToken {
|
|
||||||
pub line: u32,
|
|
||||||
pub byte_col: u32,
|
|
||||||
pub byte_len: u32,
|
|
||||||
pub kind: u32,
|
|
||||||
pub mods: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Index of byte offsets at the start of each line. Built once per request
|
|
||||||
/// so multi-line span splitting and UTF-16 conversion stay linear in
|
|
||||||
/// document size.
|
|
||||||
pub struct LineIndex {
|
|
||||||
pub starts: Vec<usize>,
|
|
||||||
pub total: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LineIndex {
|
|
||||||
pub fn new(text: &str) -> Self {
|
|
||||||
let mut starts = vec![0];
|
|
||||||
for (i, b) in text.bytes().enumerate() {
|
|
||||||
if b == b'\n' {
|
|
||||||
starts.push(i + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Self {
|
|
||||||
starts,
|
|
||||||
total: text.len(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn line_byte_range(&self, line: u32) -> (usize, usize) {
|
|
||||||
let start = self
|
|
||||||
.starts
|
|
||||||
.get(line as usize)
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(self.total);
|
|
||||||
let end = self
|
|
||||||
.starts
|
|
||||||
.get(line as usize + 1)
|
|
||||||
.map(|&s| s.saturating_sub(1))
|
|
||||||
.unwrap_or(self.total);
|
|
||||||
(start, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn line_str<'a>(&self, line: u32, text: &'a str) -> &'a str {
|
|
||||||
let (s, e) = self.line_byte_range(line);
|
|
||||||
&text[s..e]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Collection =====
|
|
||||||
|
|
||||||
pub fn collect_tokens(doc: &DocumentNode, text: &str) -> Vec<RawToken> {
|
|
||||||
let idx = LineIndex::new(text);
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for block in &doc.children {
|
|
||||||
emit_block(block, text, &idx, &mut out);
|
|
||||||
}
|
|
||||||
out.sort_by_key(|t| (t.line, t.byte_col));
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_block(block: &BlockNode, text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
|
||||||
match block {
|
|
||||||
BlockNode::Heading(h) => emit_heading(h, text, idx, out),
|
|
||||||
BlockNode::Paragraph(p) => emit_inlines(&p.children, text, idx, out),
|
|
||||||
BlockNode::HorizontalRule(_) => {}
|
|
||||||
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
|
||||||
for child in children {
|
|
||||||
emit_block(child, text, idx, out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BlockNode::Preformatted(p) => split_span(p.span, text, idx, TOKEN_CODE_BLOCK, 0, out),
|
|
||||||
BlockNode::MathBlock(m) => split_span(m.span, text, idx, TOKEN_MATH_BLOCK, 0, out),
|
|
||||||
BlockNode::List(ListNode { items, .. }) => {
|
|
||||||
for item in items {
|
|
||||||
emit_list_item(item, text, idx, out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BlockNode::DefinitionList(dl) => {
|
|
||||||
for item in &dl.items {
|
|
||||||
if let Some(term) = &item.term {
|
|
||||||
if let (Some(first), Some(last)) = (term.first(), term.last()) {
|
|
||||||
let s = Span::new(first.span().start, last.span().end);
|
|
||||||
split_span(s, text, idx, TOKEN_DEFINITION_TERM, 0, out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for def in &item.definitions {
|
|
||||||
emit_inlines(def, text, idx, out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BlockNode::Table(TableNode { rows, .. }) => {
|
|
||||||
for row in rows {
|
|
||||||
for cell in &row.cells {
|
|
||||||
emit_inlines(&cell.children, text, idx, out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BlockNode::Comment(c) => split_span(c.span, text, idx, TOKEN_COMMENT, 0, out),
|
|
||||||
BlockNode::Tag(t) => split_span(t.span, text, idx, TOKEN_TAG, 0, out),
|
|
||||||
BlockNode::Error(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_heading(h: &HeadingNode, text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
|
||||||
let mut mods = 0u32;
|
|
||||||
mods |= match h.level {
|
|
||||||
1 => MOD_LEVEL1,
|
|
||||||
2 => MOD_LEVEL2,
|
|
||||||
3 => MOD_LEVEL3,
|
|
||||||
4 => MOD_LEVEL4,
|
|
||||||
5 => MOD_LEVEL5,
|
|
||||||
_ => MOD_LEVEL6,
|
|
||||||
};
|
|
||||||
if h.centered {
|
|
||||||
mods |= MOD_CENTERED;
|
|
||||||
}
|
|
||||||
split_span(h.span, text, idx, TOKEN_HEADING, mods, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_list_item(item: &ListItemNode, text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
|
||||||
emit_inlines(&item.children, text, idx, out);
|
|
||||||
if let Some(sublist) = &item.sublist {
|
|
||||||
for child in &sublist.items {
|
|
||||||
emit_list_item(child, text, idx, out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_inlines(inlines: &[InlineNode], text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
|
||||||
for n in inlines {
|
|
||||||
let (kind, span) = match n {
|
|
||||||
InlineNode::Text(_) | InlineNode::SoftBreak(_) => continue,
|
|
||||||
InlineNode::Bold(b) => (TOKEN_BOLD, b.span),
|
|
||||||
InlineNode::Italic(i) => (TOKEN_ITALIC, i.span),
|
|
||||||
InlineNode::BoldItalic(bi) => (TOKEN_BOLD_ITALIC, bi.span),
|
|
||||||
InlineNode::Strikethrough(s) => (TOKEN_STRIKETHROUGH, s.span),
|
|
||||||
InlineNode::Code(c) => (TOKEN_CODE, c.span),
|
|
||||||
InlineNode::Superscript(s) => (TOKEN_SUPERSCRIPT, s.span),
|
|
||||||
InlineNode::Subscript(s) => (TOKEN_SUBSCRIPT, s.span),
|
|
||||||
InlineNode::MathInline(m) => (TOKEN_MATH, m.span),
|
|
||||||
InlineNode::Keyword(k) => (TOKEN_KEYWORD, k.span),
|
|
||||||
InlineNode::Color(c) => (TOKEN_COLOR, c.span),
|
|
||||||
InlineNode::WikiLink(w) => (TOKEN_WIKILINK, w.span),
|
|
||||||
InlineNode::ExternalLink(e) => (TOKEN_EXTERNAL_LINK, e.span),
|
|
||||||
InlineNode::Transclusion(t) => (TOKEN_TRANSCLUSION, t.span),
|
|
||||||
InlineNode::RawUrl(u) => (TOKEN_URL, u.span),
|
|
||||||
};
|
|
||||||
split_span(span, text, idx, kind, 0, out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert a `Span` (potentially multi-line) into one or more single-line
|
|
||||||
/// `RawToken`s. The LSP wire format can't express tokens that span newlines,
|
|
||||||
/// so we split on line boundaries.
|
|
||||||
pub fn split_span(
|
|
||||||
span: Span,
|
|
||||||
text: &str,
|
|
||||||
idx: &LineIndex,
|
|
||||||
kind: u32,
|
|
||||||
mods: u32,
|
|
||||||
out: &mut Vec<RawToken>,
|
|
||||||
) {
|
|
||||||
if span.start.line == span.end.line {
|
|
||||||
let len = span.end.column.saturating_sub(span.start.column);
|
|
||||||
if len > 0 {
|
|
||||||
out.push(RawToken {
|
|
||||||
line: span.start.line,
|
|
||||||
byte_col: span.start.column,
|
|
||||||
byte_len: len,
|
|
||||||
kind,
|
|
||||||
mods,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for line in span.start.line..=span.end.line {
|
|
||||||
let line_str = idx.line_str(line, text);
|
|
||||||
let line_len = line_str.len() as u32;
|
|
||||||
let start_col = if line == span.start.line {
|
|
||||||
span.start.column
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
let end_col = if line == span.end.line {
|
|
||||||
span.end.column.min(line_len)
|
|
||||||
} else {
|
|
||||||
line_len
|
|
||||||
};
|
|
||||||
if end_col > start_col {
|
|
||||||
out.push(RawToken {
|
|
||||||
line,
|
|
||||||
byte_col: start_col,
|
|
||||||
byte_len: end_col - start_col,
|
|
||||||
kind,
|
|
||||||
mods,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Encoding =====
|
|
||||||
|
|
||||||
/// LSP wire-format encoder. Produces a flat `Vec<u32>` of
|
|
||||||
/// `(deltaLine, deltaStart, length, type, modifiers)` quintuples.
|
|
||||||
///
|
|
||||||
/// `utf8 = true` means the client opted into UTF-8 encoding, so byte offsets
|
|
||||||
/// pass through directly. Otherwise positions and lengths are converted to
|
|
||||||
/// UTF-16 code units per the LSP default.
|
|
||||||
pub fn encode(raws: &[RawToken], text: &str, idx: &LineIndex, utf8: bool) -> Vec<u32> {
|
|
||||||
let mut data = Vec::with_capacity(raws.len() * 5);
|
|
||||||
let mut prev_line = 0u32;
|
|
||||||
let mut prev_col = 0u32;
|
|
||||||
for tok in raws {
|
|
||||||
let (col, len) = if utf8 {
|
|
||||||
(tok.byte_col, tok.byte_len)
|
|
||||||
} else {
|
|
||||||
let line_str = idx.line_str(tok.line, text);
|
|
||||||
let start = utf16_count(line_str, tok.byte_col as usize);
|
|
||||||
let end = utf16_count(line_str, (tok.byte_col + tok.byte_len) as usize);
|
|
||||||
(start, end - start)
|
|
||||||
};
|
|
||||||
let delta_line = tok.line - prev_line;
|
|
||||||
let delta_col = if delta_line == 0 { col - prev_col } else { col };
|
|
||||||
data.extend_from_slice(&[delta_line, delta_col, len, tok.kind, tok.mods]);
|
|
||||||
prev_line = tok.line;
|
|
||||||
prev_col = col;
|
|
||||||
}
|
|
||||||
data
|
|
||||||
}
|
|
||||||
|
|
||||||
fn utf16_count(s: &str, byte_offset: usize) -> u32 {
|
|
||||||
let upto = byte_offset.min(s.len());
|
|
||||||
s[..upto].chars().map(char::len_utf16).sum::<usize>() as u32
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the encoded data stream for either the full document or a range
|
|
||||||
/// request.
|
|
||||||
pub fn build_data(doc: &DocumentNode, text: &str, utf8: bool, range: Option<Range>) -> Vec<u32> {
|
|
||||||
let idx = LineIndex::new(text);
|
|
||||||
let mut tokens = collect_tokens(doc, text);
|
|
||||||
if let Some(r) = range {
|
|
||||||
tokens.retain(|t| token_overlaps_range(t, &r, text, &idx, utf8));
|
|
||||||
}
|
|
||||||
encode(&tokens, text, &idx, utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn token_overlaps_range(
|
|
||||||
tok: &RawToken,
|
|
||||||
range: &Range,
|
|
||||||
text: &str,
|
|
||||||
idx: &LineIndex,
|
|
||||||
utf8: bool,
|
|
||||||
) -> bool {
|
|
||||||
let (start_char, end_char) = if utf8 {
|
|
||||||
(tok.byte_col, tok.byte_col + tok.byte_len)
|
|
||||||
} else {
|
|
||||||
let line_str = idx.line_str(tok.line, text);
|
|
||||||
let s = utf16_count(line_str, tok.byte_col as usize);
|
|
||||||
let e = utf16_count(line_str, (tok.byte_col + tok.byte_len) as usize);
|
|
||||||
(s, e)
|
|
||||||
};
|
|
||||||
let tok_start = (tok.line, start_char);
|
|
||||||
let tok_end = (tok.line, end_char);
|
|
||||||
let r_start = (range.start.line, range.start.character);
|
|
||||||
let r_end = (range.end.line, range.end.character);
|
|
||||||
tok_end > r_start && tok_start < r_end
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
//! `Wiki` aggregate — per-wiki state, even when there's only one.
|
|
||||||
//!
|
|
||||||
//! The single `Arc<RwLock<WorkspaceIndex>>` on `Backend` is lifted into a
|
|
||||||
//! `Wiki` aggregate so multi-wiki support only changes config shape, not
|
|
||||||
//! data flow. Handlers always operate on a `Wiki`; in practice the `wikis`
|
|
||||||
//! `Vec` has one entry until multi-wiki support lands.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::{Arc, RwLock};
|
|
||||||
|
|
||||||
use tower_lsp::lsp_types::Url;
|
|
||||||
|
|
||||||
use crate::config::WikiConfig;
|
|
||||||
use crate::index::WorkspaceIndex;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Default)]
|
|
||||||
pub struct WikiId(pub u32);
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Wiki {
|
|
||||||
pub id: WikiId,
|
|
||||||
// `Arc` so cloning a `Wiki` (done on every handler entry that picks a
|
|
||||||
// wiki out of the list) is a refcount bump, not a deep copy of the whole
|
|
||||||
// config — color_dic, templates, header strings, and so on.
|
|
||||||
pub config: Arc<WikiConfig>,
|
|
||||||
pub index: Arc<RwLock<WorkspaceIndex>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Wiki {
|
|
||||||
pub fn new(id: WikiId, config: WikiConfig) -> Self {
|
|
||||||
let index = WorkspaceIndex::new(Some(config.root.clone()))
|
|
||||||
.with_diary_rel_path(Some(config.diary_rel_path.clone()))
|
|
||||||
.with_file_extension(Some(config.file_extension.clone()));
|
|
||||||
let index = Arc::new(RwLock::new(index));
|
|
||||||
Self {
|
|
||||||
id,
|
|
||||||
config: Arc::new(config),
|
|
||||||
index,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True when `uri` resolves to a file under this wiki's root.
|
|
||||||
pub fn contains(&self, uri: &Url) -> bool {
|
|
||||||
let Ok(path) = uri.to_file_path() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
path.starts_with(&self.config.root)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Length of the wiki's root path (component count) — used to break
|
|
||||||
/// ties in nested-root layouts.
|
|
||||||
pub fn root_depth(&self) -> usize {
|
|
||||||
self.config.root.components().count()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn root(&self) -> &Path {
|
|
||||||
&self.config.root
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pick the wiki whose root is the longest prefix of `uri`. Returns the
|
|
||||||
/// wiki id, not a guard, so callers can release the read lock before
|
|
||||||
/// awaiting.
|
|
||||||
pub fn resolve_uri_to_wiki(wikis: &[Wiki], uri: &Url) -> Option<WikiId> {
|
|
||||||
wikis
|
|
||||||
.iter()
|
|
||||||
.filter(|w| w.contains(uri))
|
|
||||||
.max_by_key(|w| w.root_depth())
|
|
||||||
.map(|w| w.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the initial `Vec<Wiki>` from a list of `WikiConfig`s. Always
|
|
||||||
/// assigns ids by position so `WikiId(0)` is the default wiki.
|
|
||||||
pub fn build_wikis(configs: &[WikiConfig]) -> Vec<Wiki> {
|
|
||||||
configs
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, cfg)| Wiki::new(WikiId(i as u32), cfg.clone()))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
@@ -1,524 +0,0 @@
|
|||||||
//! Checkbox state transitions and the `checkbox_edit` rewriter that
|
|
||||||
//! emits `WorkspaceEdit`s for `nuwiki.list.toggleCheckbox`,
|
|
||||||
//! `nuwiki.list.cycleCheckbox`, and `nuwiki.list.rejectCheckbox`.
|
|
||||||
|
|
||||||
use nuwiki_core::listsyms::ListSyms;
|
|
||||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
||||||
use nuwiki_core::syntax::SyntaxPlugin;
|
|
||||||
use nuwiki_lsp::commands::ops;
|
|
||||||
use tower_lsp::lsp_types::{DocumentChanges, Url};
|
|
||||||
|
|
||||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
|
||||||
VimwikiSyntax::new().parse(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn syms() -> ListSyms {
|
|
||||||
ListSyms::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dummy_uri() -> Url {
|
|
||||||
Url::parse("file:///tmp/note.wiki").unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn one_text_edit(edit: tower_lsp::lsp_types::WorkspaceEdit) -> tower_lsp::lsp_types::TextEdit {
|
|
||||||
let changes = edit.changes.expect("expected changes map");
|
|
||||||
let (_, edits) = changes.into_iter().next().expect("at least one uri");
|
|
||||||
assert_eq!(edits.len(), 1, "expected exactly one text edit");
|
|
||||||
edits.into_iter().next().unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn doc_changes_for(edit: tower_lsp::lsp_types::WorkspaceEdit) -> Option<DocumentChanges> {
|
|
||||||
edit.document_changes
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== state transitions =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn toggle_state_round_trip() {
|
|
||||||
let s = syms();
|
|
||||||
assert_eq!(ops::toggle_state(&s, "[ ]").as_deref(), Some("[X]"));
|
|
||||||
assert_eq!(ops::toggle_state(&s, "[X]").as_deref(), Some("[ ]"));
|
|
||||||
assert_eq!(ops::toggle_state(&s, "[.]").as_deref(), Some("[X]"));
|
|
||||||
assert_eq!(ops::toggle_state(&s, "[-]").as_deref(), Some("[ ]"));
|
|
||||||
assert_eq!(ops::toggle_state(&s, "[?]").as_deref(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_state_walks_progression() {
|
|
||||||
let s = syms();
|
|
||||||
assert_eq!(ops::cycle_state(&s, "[ ]").as_deref(), Some("[.]"));
|
|
||||||
assert_eq!(ops::cycle_state(&s, "[.]").as_deref(), Some("[o]"));
|
|
||||||
assert_eq!(ops::cycle_state(&s, "[o]").as_deref(), Some("[O]"));
|
|
||||||
assert_eq!(ops::cycle_state(&s, "[O]").as_deref(), Some("[X]"));
|
|
||||||
assert_eq!(ops::cycle_state(&s, "[X]").as_deref(), Some("[ ]"));
|
|
||||||
assert_eq!(ops::cycle_state(&s, "[-]").as_deref(), Some("[ ]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_state_back_is_exact_inverse() {
|
|
||||||
// `glp` walks the progression in reverse — every step must undo the
|
|
||||||
// matching `cycle_state` step.
|
|
||||||
let s = syms();
|
|
||||||
assert_eq!(ops::cycle_state_back(&s, "[ ]").as_deref(), Some("[X]"));
|
|
||||||
assert_eq!(ops::cycle_state_back(&s, "[X]").as_deref(), Some("[O]"));
|
|
||||||
assert_eq!(ops::cycle_state_back(&s, "[O]").as_deref(), Some("[o]"));
|
|
||||||
assert_eq!(ops::cycle_state_back(&s, "[o]").as_deref(), Some("[.]"));
|
|
||||||
assert_eq!(ops::cycle_state_back(&s, "[.]").as_deref(), Some("[ ]"));
|
|
||||||
assert_eq!(ops::cycle_state_back(&s, "[-]").as_deref(), Some("[ ]"));
|
|
||||||
// Composing forward then backward returns to the start for every
|
|
||||||
// in-cycle marker.
|
|
||||||
for m in ["[ ]", "[.]", "[o]", "[O]", "[X]"] {
|
|
||||||
let fwd = ops::cycle_state(&s, m).unwrap();
|
|
||||||
assert_eq!(ops::cycle_state_back(&s, &fwd).as_deref(), Some(m));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reject_state_toggles_dash_marker() {
|
|
||||||
let s = syms();
|
|
||||||
assert_eq!(ops::reject_state(&s, "[ ]").as_deref(), Some("[-]"));
|
|
||||||
assert_eq!(ops::reject_state(&s, "[X]").as_deref(), Some("[-]"));
|
|
||||||
assert_eq!(ops::reject_state(&s, "[-]").as_deref(), Some("[ ]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn custom_palette_three_symbols() {
|
|
||||||
// A 3-glyph palette `" x✓"`: empty/half/done. `[x]` is the only
|
|
||||||
// intermediate; cycling walks ` `→`x`→`✓`→` `.
|
|
||||||
let s = ListSyms::new(" x✓");
|
|
||||||
assert_eq!(ops::cycle_state(&s, "[ ]").as_deref(), Some("[x]"));
|
|
||||||
assert_eq!(ops::cycle_state(&s, "[x]").as_deref(), Some("[✓]"));
|
|
||||||
assert_eq!(ops::cycle_state(&s, "[✓]").as_deref(), Some("[ ]"));
|
|
||||||
// Default-palette glyphs are not in this palette → no transition.
|
|
||||||
assert_eq!(ops::cycle_state(&s, "[o]").as_deref(), None);
|
|
||||||
// Toggle still maps empty→done, done→empty.
|
|
||||||
assert_eq!(ops::toggle_state(&s, "[ ]").as_deref(), Some("[✓]"));
|
|
||||||
assert_eq!(ops::toggle_state(&s, "[✓]").as_deref(), Some("[ ]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== checkbox_edit =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn toggle_empty_checkbox_to_done() {
|
|
||||||
let src = "- [ ] task\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
0,
|
|
||||||
4,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::toggle_state,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("toggle edit");
|
|
||||||
let te = one_text_edit(edit);
|
|
||||||
assert_eq!(te.new_text, "[X]");
|
|
||||||
assert_eq!(te.range.start.character, 2); // bytes 2..5 = "[ ]"
|
|
||||||
assert_eq!(te.range.end.character, 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_advances_partial_states() {
|
|
||||||
let src = "- [.] half\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::cycle_state,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("cycle edit");
|
|
||||||
let te = one_text_edit(edit);
|
|
||||||
assert_eq!(te.new_text, "[o]");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reject_replaces_empty_with_dash() {
|
|
||||||
let src = "* [ ] thing\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::reject_state,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("reject edit");
|
|
||||||
let te = one_text_edit(edit);
|
|
||||||
assert_eq!(te.new_text, "[-]");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn checkbox_edit_returns_none_on_plain_list_item() {
|
|
||||||
// No `[…]` at all → no edit.
|
|
||||||
let src = "- plain item\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::toggle_state,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert!(edit.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn checkbox_edit_returns_none_off_list() {
|
|
||||||
let src = "just a paragraph\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::toggle_state,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert!(edit.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn checkbox_edit_finds_item_in_sublist() {
|
|
||||||
let src = "- top\n - [ ] nested\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
// Cursor on line 1, the nested item.
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
1,
|
|
||||||
5,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::toggle_state,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("edit on nested item");
|
|
||||||
let te = one_text_edit(edit);
|
|
||||||
assert_eq!(te.new_text, "[X]");
|
|
||||||
assert_eq!(te.range.start.line, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== sanity: workspace edit shape =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn checkbox_edit_uses_changes_map_not_document_changes() {
|
|
||||||
let src = "- [ ] task\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
0,
|
|
||||||
4,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::toggle_state,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(doc_changes_for(edit.clone()).is_none());
|
|
||||||
assert!(edit.changes.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== parent propagation (vimwiki listsyms_propagate) =====
|
|
||||||
|
|
||||||
/// Collect the (line, new_text) pairs for every TextEdit in the result,
|
|
||||||
/// sorted by line. Lets propagation tests assert the parent edit
|
|
||||||
/// without caring about edit ordering.
|
|
||||||
fn edits_by_line(edit: tower_lsp::lsp_types::WorkspaceEdit) -> Vec<(u32, String)> {
|
|
||||||
let changes = edit.changes.expect("expected changes map");
|
|
||||||
let (_, edits) = changes.into_iter().next().expect("at least one uri");
|
|
||||||
let mut pairs: Vec<(u32, String)> = edits
|
|
||||||
.into_iter()
|
|
||||||
.map(|e| (e.range.start.line, e.new_text))
|
|
||||||
.collect();
|
|
||||||
pairs.sort_by_key(|(line, _)| *line);
|
|
||||||
pairs
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn propagate_one_of_four_done_marks_parent_quarter() {
|
|
||||||
// Toggle the first child of a 4-child parent. Parent should land at
|
|
||||||
// [.] (Quarter, 25%).
|
|
||||||
let src = "\
|
|
||||||
- [ ] root
|
|
||||||
- [ ] sub1
|
|
||||||
- [ ] sub2
|
|
||||||
- [ ] sub3
|
|
||||||
- [ ] sub4
|
|
||||||
";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
1,
|
|
||||||
6,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::toggle_state,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("toggle edit");
|
|
||||||
let pairs = edits_by_line(edit);
|
|
||||||
assert_eq!(pairs, vec![(0, "[.]".into()), (1, "[X]".into())]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn propagate_two_of_four_done_marks_parent_half() {
|
|
||||||
// sub1 is already done; toggling sub2 → done leaves rate=50% → [o].
|
|
||||||
let src = "\
|
|
||||||
- [ ] root
|
|
||||||
- [X] sub1
|
|
||||||
- [ ] sub2
|
|
||||||
- [ ] sub3
|
|
||||||
- [ ] sub4
|
|
||||||
";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
2,
|
|
||||||
6,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::toggle_state,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("toggle edit");
|
|
||||||
let pairs = edits_by_line(edit);
|
|
||||||
assert_eq!(pairs, vec![(0, "[o]".into()), (2, "[X]".into())]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn propagate_three_of_four_done_marks_parent_three_quarters() {
|
|
||||||
let src = "\
|
|
||||||
- [ ] root
|
|
||||||
- [X] sub1
|
|
||||||
- [X] sub2
|
|
||||||
- [ ] sub3
|
|
||||||
- [ ] sub4
|
|
||||||
";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
3,
|
|
||||||
6,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::toggle_state,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("toggle edit");
|
|
||||||
let pairs = edits_by_line(edit);
|
|
||||||
assert_eq!(pairs, vec![(0, "[O]".into()), (3, "[X]".into())]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn propagate_all_done_marks_parent_done() {
|
|
||||||
let src = "\
|
|
||||||
- [ ] root
|
|
||||||
- [X] sub1
|
|
||||||
- [X] sub2
|
|
||||||
- [X] sub3
|
|
||||||
- [ ] sub4
|
|
||||||
";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
4,
|
|
||||||
6,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::toggle_state,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("toggle edit");
|
|
||||||
let pairs = edits_by_line(edit);
|
|
||||||
assert_eq!(pairs, vec![(0, "[X]".into()), (4, "[X]".into())]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn propagate_untoggle_drops_parent_back() {
|
|
||||||
// Untoggling the only completed child should put parent back to [ ].
|
|
||||||
let src = "\
|
|
||||||
- [.] root
|
|
||||||
- [X] sub1
|
|
||||||
- [ ] sub2
|
|
||||||
- [ ] sub3
|
|
||||||
- [ ] sub4
|
|
||||||
";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
1,
|
|
||||||
6,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::toggle_state,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("toggle edit");
|
|
||||||
let pairs = edits_by_line(edit);
|
|
||||||
assert_eq!(pairs, vec![(0, "[ ]".into()), (1, "[ ]".into())]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn propagate_disabled_only_emits_leaf_edit() {
|
|
||||||
let src = "\
|
|
||||||
- [ ] root
|
|
||||||
- [ ] sub1
|
|
||||||
- [ ] sub2
|
|
||||||
- [ ] sub3
|
|
||||||
- [ ] sub4
|
|
||||||
";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
1,
|
|
||||||
6,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::toggle_state,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.expect("toggle edit");
|
|
||||||
let pairs = edits_by_line(edit);
|
|
||||||
assert_eq!(pairs, vec![(1, "[X]".into())]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn propagate_skips_parent_without_checkbox() {
|
|
||||||
// Parent has no `[…]`, so propagation has nothing to update.
|
|
||||||
let src = "\
|
|
||||||
- root
|
|
||||||
- [ ] sub1
|
|
||||||
- [ ] sub2
|
|
||||||
";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
1,
|
|
||||||
6,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::toggle_state,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("toggle edit");
|
|
||||||
let pairs = edits_by_line(edit);
|
|
||||||
assert_eq!(pairs, vec![(1, "[X]".into())]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn propagate_walks_multiple_levels() {
|
|
||||||
// Toggle the deepest leaf: grandparent has 1 child (parent), parent
|
|
||||||
// has 2 children. Sub1 done → parent half → grandparent half.
|
|
||||||
let src = "\
|
|
||||||
- [ ] grand
|
|
||||||
- [ ] parent
|
|
||||||
- [ ] sub1
|
|
||||||
- [ ] sub2
|
|
||||||
";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
2,
|
|
||||||
8,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::toggle_state,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("toggle edit");
|
|
||||||
let pairs = edits_by_line(edit);
|
|
||||||
assert_eq!(
|
|
||||||
pairs,
|
|
||||||
vec![(0, "[o]".into()), (1, "[o]".into()), (2, "[X]".into())]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn propagate_all_rejected_marks_parent_rejected() {
|
|
||||||
// Every counted child is rejected → parent becomes [-].
|
|
||||||
let src = "\
|
|
||||||
- [ ] root
|
|
||||||
- [-] sub1
|
|
||||||
- [ ] sub2
|
|
||||||
";
|
|
||||||
let doc = parse(src);
|
|
||||||
// Reject sub2 so both children are rejected.
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
2,
|
|
||||||
6,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::reject_state,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("reject edit");
|
|
||||||
let pairs = edits_by_line(edit);
|
|
||||||
assert_eq!(pairs, vec![(0, "[-]".into()), (2, "[-]".into())]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn propagate_rejected_sibling_counts_as_done_for_average() {
|
|
||||||
// sub1 rejected, sub2 toggled to done → both "complete" → parent [X].
|
|
||||||
let src = "\
|
|
||||||
- [ ] root
|
|
||||||
- [-] sub1
|
|
||||||
- [ ] sub2
|
|
||||||
";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::checkbox_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&dummy_uri(),
|
|
||||||
2,
|
|
||||||
6,
|
|
||||||
true,
|
|
||||||
&syms(),
|
|
||||||
ops::toggle_state,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("toggle edit");
|
|
||||||
let pairs = edits_by_line(edit);
|
|
||||||
assert_eq!(pairs, vec![(0, "[X]".into()), (2, "[X]".into())]);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
//! `color_dic` → `HtmlRenderer` integration.
|
|
||||||
//!
|
|
||||||
//! Drives the renderer directly so we don't have to spin up the full
|
|
||||||
//! export pipeline. The end-to-end path (export command → renderer
|
|
||||||
//! with `cfg.color_dic` plumbed through) is integration-tested via the
|
|
||||||
//! export tests.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use nuwiki_core::ast::{ColorNode, DocumentNode, InlineNode, ParagraphNode, Span, TextNode};
|
|
||||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
|
||||||
use nuwiki_lsp::config::config_from_json;
|
|
||||||
|
|
||||||
fn doc_with_color(name: &str, text: &str) -> DocumentNode {
|
|
||||||
let span = Span::default();
|
|
||||||
DocumentNode {
|
|
||||||
span,
|
|
||||||
metadata: Default::default(),
|
|
||||||
children: vec![nuwiki_core::ast::BlockNode::Paragraph(ParagraphNode {
|
|
||||||
span,
|
|
||||||
children: vec![InlineNode::Color(ColorNode {
|
|
||||||
span,
|
|
||||||
color: name.into(),
|
|
||||||
children: vec![InlineNode::Text(TextNode {
|
|
||||||
span,
|
|
||||||
content: text.into(),
|
|
||||||
})],
|
|
||||||
})],
|
|
||||||
})],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn color_falls_back_to_class_when_dict_empty() {
|
|
||||||
let doc = doc_with_color("red", "hello");
|
|
||||||
let r = HtmlRenderer::new();
|
|
||||||
let out = r.render_to_string(&doc).unwrap();
|
|
||||||
assert!(out.contains(r#"<span class="color-red">"#), "got: {out}");
|
|
||||||
assert!(out.contains("hello</span>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn color_dic_emits_inline_style_when_name_listed() {
|
|
||||||
let mut dict = HashMap::new();
|
|
||||||
dict.insert("red".to_string(), "#ff0000".to_string());
|
|
||||||
let doc = doc_with_color("red", "hello");
|
|
||||||
let r = HtmlRenderer::new().with_colors(dict);
|
|
||||||
let out = r.render_to_string(&doc).unwrap();
|
|
||||||
assert!(
|
|
||||||
out.contains(r#"<span style="color:#ff0000">"#),
|
|
||||||
"got: {out}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unlisted_color_falls_back_even_with_dict_set() {
|
|
||||||
let mut dict = HashMap::new();
|
|
||||||
dict.insert("red".to_string(), "#ff0000".to_string());
|
|
||||||
let doc = doc_with_color("violet", "x");
|
|
||||||
let r = HtmlRenderer::new().with_colors(dict);
|
|
||||||
let out = r.render_to_string(&doc).unwrap();
|
|
||||||
assert!(out.contains(r#"<span class="color-violet">"#), "got: {out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn config_from_json_accepts_color_dic() {
|
|
||||||
let cfg = config_from_json(serde_json::json!({
|
|
||||||
"wikis": [
|
|
||||||
{ "root": "/tmp/c", "color_dic": { "red": "red", "blue": "blue" } },
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
let d = &cfg.wikis[0].html.color_dic;
|
|
||||||
assert_eq!(d.get("red").map(String::as_str), Some("red"));
|
|
||||||
assert_eq!(d.get("blue").map(String::as_str), Some("blue"));
|
|
||||||
}
|
|
||||||
@@ -1,593 +0,0 @@
|
|||||||
//! Command-coverage suite: at least one behavioural test for every
|
|
||||||
//! documented `:Nuwiki*` / `:Vimwiki*` command (see the COMMANDS section
|
|
||||||
//! of `doc/nuwiki.txt`).
|
|
||||||
//!
|
|
||||||
//! The user-facing commands funnel through `workspace/executeCommand`
|
|
||||||
//! into the Backend dispatcher, or — for follow/backlinks/rename — through
|
|
||||||
//! the standard LSP requests. The Backend itself owns a live `Client` and
|
|
||||||
//! can't be built in an integration test, so each case drives the *pure*
|
|
||||||
//! building-block the command relies on (the same functions the dispatcher
|
|
||||||
//! wraps). This file is organised to mirror the doc's command groups so a
|
|
||||||
//! reader can confirm every command has coverage.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use nuwiki_core::ast::{InlineNode, LinkKind, LinkTarget};
|
|
||||||
use nuwiki_core::date::DiaryDate;
|
|
||||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
||||||
use nuwiki_core::syntax::SyntaxPlugin;
|
|
||||||
use nuwiki_lsp::commands::{export_ops, ops, COMMANDS};
|
|
||||||
use nuwiki_lsp::config::WikiConfig;
|
|
||||||
use nuwiki_lsp::index::WorkspaceIndex;
|
|
||||||
use nuwiki_lsp::nav::find_inline_at;
|
|
||||||
use nuwiki_lsp::rename::{apply_links_space_char, build_new_uri, rewrite_wikilink_target};
|
|
||||||
use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki};
|
|
||||||
use nuwiki_lsp::{diary, export};
|
|
||||||
use tower_lsp::lsp_types::Url;
|
|
||||||
|
|
||||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
|
||||||
VimwikiSyntax::new().parse(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wiki_cfg(root: &str) -> WikiConfig {
|
|
||||||
WikiConfig::from_root(PathBuf::from(root))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a workspace index rooted at `root` with the given `name -> source`
|
|
||||||
/// pages, mirroring the helper used by the other command suites.
|
|
||||||
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
|
|
||||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
|
||||||
for (name, src) in pages {
|
|
||||||
let uri = Url::from_file_path(format!("{root}/{name}.wiki")).unwrap();
|
|
||||||
idx.upsert(uri, &parse(src));
|
|
||||||
}
|
|
||||||
idx
|
|
||||||
}
|
|
||||||
|
|
||||||
fn diary_index(root: &str, dates: &[&str]) -> WorkspaceIndex {
|
|
||||||
let mut idx =
|
|
||||||
WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into()));
|
|
||||||
for d in dates {
|
|
||||||
let uri = Url::from_file_path(format!("{root}/diary/{d}.wiki")).unwrap();
|
|
||||||
idx.upsert(uri, &parse("= entry =\n"));
|
|
||||||
}
|
|
||||||
idx
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Group 1: Wiki / navigation
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
// :NuwikiIndex — open wiki N's index page.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_index_resolves_index_page() {
|
|
||||||
let idx = build_index("/wiki", &[("index", "= Home =\n"), ("Other", "= O =\n")]);
|
|
||||||
let page = idx.page_by_name("index").expect("index page indexed");
|
|
||||||
assert!(page.uri.as_str().ends_with("/wiki/index.wiki"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiTabIndex — same target as :NuwikiIndex, opened in a new tab.
|
|
||||||
// The tab-vs-current split is an editor concern; the resolved target is
|
|
||||||
// identical, and both verbs are advertised as executeCommands.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_tab_index_advertised_alongside_index() {
|
|
||||||
assert!(COMMANDS.contains(&"nuwiki.wiki.openIndex"));
|
|
||||||
assert!(COMMANDS.contains(&"nuwiki.wiki.tabOpenIndex"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiUISelect — pick a wiki from the configured list.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_ui_select_lists_configured_wikis() {
|
|
||||||
let cfgs = vec![wiki_cfg("/a/personal"), wiki_cfg("/b/work")];
|
|
||||||
let wikis = build_wikis(&cfgs);
|
|
||||||
assert_eq!(wikis.len(), 2);
|
|
||||||
assert_eq!(wikis[0].config.name, "personal");
|
|
||||||
assert_eq!(wikis[1].config.name, "work");
|
|
||||||
|
|
||||||
// Selection by URI picks the wiki that actually owns the file.
|
|
||||||
let uri = Url::from_file_path("/b/work/Page.wiki").unwrap();
|
|
||||||
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(wikis[1].id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiGoto {page} — open `{page}.wiki` by name.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_goto_opens_page_by_name() {
|
|
||||||
let idx = build_index("/wiki", &[("index", "= H =\n"), ("Recipes", "= R =\n")]);
|
|
||||||
let page = idx
|
|
||||||
.page_by_name("Recipes")
|
|
||||||
.expect("page resolvable by name");
|
|
||||||
assert!(page.uri.as_str().ends_with("/wiki/Recipes.wiki"));
|
|
||||||
assert!(idx.page_by_name("Missing").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiFollowLink — follow the link under the cursor.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_follow_link_resolves_target_under_cursor() {
|
|
||||||
let idx = build_index(
|
|
||||||
"/wiki",
|
|
||||||
&[("Home", "see [[About]]\n"), ("About", "= A =\n")],
|
|
||||||
);
|
|
||||||
let doc = parse("see [[About]]\n");
|
|
||||||
// Byte col 8 sits inside "[[About]]" (starts at byte 4).
|
|
||||||
let node = find_inline_at(&doc, 0, 8).expect("wikilink under cursor");
|
|
||||||
let InlineNode::WikiLink(w) = node else {
|
|
||||||
panic!("expected a wikilink, got {node:?}");
|
|
||||||
};
|
|
||||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
|
||||||
assert_eq!(idx.resolve(&w.target, "Home"), Some(&about));
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiBacklinks — every reference to the current page.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_backlinks_lists_referrers() {
|
|
||||||
let idx = build_index(
|
|
||||||
"/wiki",
|
|
||||||
&[
|
|
||||||
("Home", "[[About]]\n"),
|
|
||||||
("Contact", "see [[About]] too\n"),
|
|
||||||
("About", "= A =\n"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let back = idx.backlinks_for("About");
|
|
||||||
assert_eq!(back.len(), 2);
|
|
||||||
assert!(idx.backlinks_for("Home").is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiNextLink / :NuwikiPrevLink — jump to the next / previous wikilink.
|
|
||||||
// The jump itself is editor-side cursor movement; what the server provides
|
|
||||||
// is the ordered set of links on the page.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_next_prev_link_walk_links_in_document_order() {
|
|
||||||
let doc = parse("intro [[First]] mid [[Second]] end [[Third]]\n");
|
|
||||||
let links = ops::collect_wiki_links(&doc);
|
|
||||||
let targets: Vec<&str> = links
|
|
||||||
.iter()
|
|
||||||
.filter_map(|l| l.target.path.as_deref())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(targets, vec!["First", "Second", "Third"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiBaddLink — add the target of the link under the cursor to the
|
|
||||||
// buffer list. The editor :badds whatever URI the link resolves to.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_badd_link_resolves_target_uri() {
|
|
||||||
let idx = build_index(
|
|
||||||
"/wiki",
|
|
||||||
&[("Home", "[[Notes/Todo]]\n"), ("Notes/Todo", "= T =\n")],
|
|
||||||
);
|
|
||||||
let target = LinkTarget {
|
|
||||||
kind: LinkKind::Wiki,
|
|
||||||
path: Some("Notes/Todo".into()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let expected = Url::from_file_path("/wiki/Notes/Todo.wiki").unwrap();
|
|
||||||
assert_eq!(idx.resolve(&target, "Home"), Some(&expected));
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiRenameFile — rename the page and rewrite every inbound link.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_rename_file_builds_uri_and_rewrites_links() {
|
|
||||||
let new_uri = build_new_uri(&PathBuf::from("/wiki"), "New Name", ".wiki").unwrap();
|
|
||||||
assert!(new_uri.as_str().ends_with("/wiki/New%20Name.wiki"));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
rewrite_wikilink_target("[[Old Name|caption]]", "New Name"),
|
|
||||||
Some("[[New Name|caption]]".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// links_space_char rewrites spaces in the link target / on-disk path while
|
|
||||||
// the default of a single space keeps the name verbatim.
|
|
||||||
#[test]
|
|
||||||
fn links_space_char_replaces_spaces_in_target_path() {
|
|
||||||
// Default " " — identity, spaces preserved.
|
|
||||||
assert_eq!(apply_links_space_char("My Page", " "), "My Page");
|
|
||||||
// Custom char — spaces become the configured glyph.
|
|
||||||
assert_eq!(apply_links_space_char("My Page", "_"), "My_Page");
|
|
||||||
// Subdir separators are untouched; only spaces within segments change.
|
|
||||||
assert_eq!(
|
|
||||||
apply_links_space_char("sub dir/My Page", "_"),
|
|
||||||
"sub_dir/My_Page"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The transformed name flows into both the new URI and the rewritten link.
|
|
||||||
let renamed = apply_links_space_char("New Page", "-");
|
|
||||||
let new_uri = build_new_uri(&PathBuf::from("/wiki"), &renamed, ".wiki").unwrap();
|
|
||||||
assert!(new_uri.as_str().ends_with("/wiki/New-Page.wiki"));
|
|
||||||
assert_eq!(
|
|
||||||
rewrite_wikilink_target("[[Old Name|caption]]", &renamed),
|
|
||||||
Some("[[New-Page|caption]]".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiDeleteFile — delete the current page and its on-disk file.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_delete_file_emits_delete_workspace_edit() {
|
|
||||||
use tower_lsp::lsp_types::{DocumentChangeOperation, DocumentChanges, ResourceOp};
|
|
||||||
let uri = Url::parse("file:///wiki/Doomed.wiki").unwrap();
|
|
||||||
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
|
|
||||||
b.file_op(nuwiki_lsp::edits::op_delete(uri.clone()));
|
|
||||||
let we = b.build();
|
|
||||||
|
|
||||||
let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else {
|
|
||||||
panic!("expected resource operations");
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
&ops[0],
|
|
||||||
DocumentChangeOperation::Op(ResourceOp::Delete(d)) if d.uri == uri
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Group 2: Diary
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
// :NuwikiMakeDiaryNote — open today's entry.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_make_diary_note_targets_todays_file() {
|
|
||||||
let cfg = wiki_cfg("/wiki");
|
|
||||||
let today = DiaryDate::from_ymd(2026, 5, 12).unwrap();
|
|
||||||
let uri = diary::uri_for_date(&cfg, &today).unwrap();
|
|
||||||
assert!(uri.as_str().ends_with("/diary/2026-05-12.wiki"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiMakeYesterdayDiaryNote / :NuwikiMakeTomorrowDiaryNote — step the
|
|
||||||
// diary back / forward by one period at the daily cadence.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_yesterday_and_tomorrow_target_adjacent_files() {
|
|
||||||
let cfg = wiki_cfg("/wiki");
|
|
||||||
let yesterday = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
|
||||||
let tomorrow = DiaryDate::from_ymd(2026, 5, 13).unwrap();
|
|
||||||
assert!(diary::uri_for_date(&cfg, &yesterday)
|
|
||||||
.unwrap()
|
|
||||||
.as_str()
|
|
||||||
.ends_with("/diary/2026-05-11.wiki"));
|
|
||||||
assert!(diary::uri_for_date(&cfg, &tomorrow)
|
|
||||||
.unwrap()
|
|
||||||
.as_str()
|
|
||||||
.ends_with("/diary/2026-05-13.wiki"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiDiaryIndex — open the diary index page.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_diary_index_resolves_index_uri() {
|
|
||||||
let cfg = wiki_cfg("/wiki");
|
|
||||||
let uri = diary::index_uri(&cfg).unwrap();
|
|
||||||
assert!(uri.as_str().ends_with("/diary/diary.wiki"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiDiaryGenerateLinks — rebuild the diary index from disk entries.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_diary_generate_links_builds_grouped_body() {
|
|
||||||
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-04-30"]);
|
|
||||||
let entries = diary::list_entries(&idx);
|
|
||||||
let body = diary::build_index_body(
|
|
||||||
&entries,
|
|
||||||
"diary",
|
|
||||||
"Diary",
|
|
||||||
diary::DiarySort::Desc,
|
|
||||||
1,
|
|
||||||
&[],
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert!(body.starts_with("= Diary ="));
|
|
||||||
assert!(body.contains("2026-05-12"));
|
|
||||||
assert!(body.contains("2026-05-10"));
|
|
||||||
assert!(body.contains("2026-04-30"));
|
|
||||||
// Newest entry appears before the older one within the same month.
|
|
||||||
let p12 = body.find("2026-05-12").unwrap();
|
|
||||||
let p10 = body.find("2026-05-10").unwrap();
|
|
||||||
assert!(p12 < p10, "expected newest-first ordering");
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiDiaryNextDay / :NuwikiDiaryPrevDay — walk indexed entries.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_diary_next_and_prev_day_walk_entries() {
|
|
||||||
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-05-15"]);
|
|
||||||
let from = DiaryDate::from_ymd(2026, 5, 12).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
diary::next_entry(&idx, &from).unwrap().date.format(),
|
|
||||||
"2026-05-15"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
diary::prev_entry(&idx, &from).unwrap().date.format(),
|
|
||||||
"2026-05-10"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Group 3: Page generation
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
// :NuwikiTOC — generate / refresh the table of contents.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_toc_generates_nested_contents() {
|
|
||||||
let src = "= Top =\n== Sub ==\ntext\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let uri = Url::from_file_path("/wiki/Page.wiki").unwrap();
|
|
||||||
let edit =
|
|
||||||
ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0, 0, None).expect("toc edit produced");
|
|
||||||
assert!(edit.changes.is_some() || edit.document_changes.is_some());
|
|
||||||
|
|
||||||
let toc = ops::build_toc_text(
|
|
||||||
&[
|
|
||||||
(1, "Top".into(), "top".into()),
|
|
||||||
(2, "Sub".into(), "sub".into()),
|
|
||||||
],
|
|
||||||
"Contents",
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert!(toc.contains("= Contents ="));
|
|
||||||
assert!(toc.contains("- [[#top|Top]]"));
|
|
||||||
assert!(toc.contains(" - [[#sub|Sub]]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiGenerateLinks — insert a flat list of every page in the wiki.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_generate_links_lists_all_pages_excluding_current() {
|
|
||||||
let pages = vec!["About".to_string(), "Home".to_string(), "Notes".to_string()];
|
|
||||||
let text = ops::build_links_text(&pages, "Links", 1, Some("Home"), 0, None);
|
|
||||||
assert!(text.contains("= Links ="));
|
|
||||||
assert!(text.contains("- [[About]]"));
|
|
||||||
assert!(text.contains("- [[Notes]]"));
|
|
||||||
assert!(!text.contains("[[Home]]"), "current page excluded");
|
|
||||||
|
|
||||||
let src = "= Existing =\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
|
||||||
assert!(ops::links_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&uri,
|
|
||||||
"Home",
|
|
||||||
&pages,
|
|
||||||
true,
|
|
||||||
"Generated Links",
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
None,
|
|
||||||
None
|
|
||||||
)
|
|
||||||
.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiCheckLinks — surface broken links across the workspace.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_check_links_distinguishes_broken_from_resolvable() {
|
|
||||||
let idx = build_index(
|
|
||||||
"/wiki",
|
|
||||||
&[("Home", "[[About]] [[Ghost]]\n"), ("About", "= A =\n")],
|
|
||||||
);
|
|
||||||
let good = LinkTarget {
|
|
||||||
kind: LinkKind::Wiki,
|
|
||||||
path: Some("About".into()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let broken = LinkTarget {
|
|
||||||
kind: LinkKind::Wiki,
|
|
||||||
path: Some("Ghost".into()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(idx.resolve(&good, "Home").is_some());
|
|
||||||
assert!(
|
|
||||||
idx.resolve(&broken, "Home").is_none(),
|
|
||||||
"broken link unresolved"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Group 4: Tags
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
// :NuwikiSearchTags {tag} — every `:tag:` occurrence.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_search_tags_finds_occurrences() {
|
|
||||||
let idx = build_index(
|
|
||||||
"/wiki",
|
|
||||||
&[("Home", ":alpha:beta:\n"), ("Work", ":alpha:\n")],
|
|
||||||
);
|
|
||||||
let hits = ops::tag_hits(&idx, "alpha");
|
|
||||||
assert_eq!(hits.len(), 2);
|
|
||||||
assert!(hits.iter().all(|h| h.name == "alpha"));
|
|
||||||
assert!(ops::tag_hits(&idx, "nonexistent").is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiGenerateTagLinks [tag] — section linking every page with a tag.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_generate_tag_links_builds_section() {
|
|
||||||
let idx = build_index(
|
|
||||||
"/wiki",
|
|
||||||
&[
|
|
||||||
("Home", ":alpha:\n"),
|
|
||||||
("Work", ":alpha:\n"),
|
|
||||||
("Misc", ":beta:\n"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let by_tag = ops::tag_pages_snapshot(&idx);
|
|
||||||
|
|
||||||
let single = ops::build_tag_links_text(&by_tag, Some("alpha"), "Generated Tags", 1, 0).unwrap();
|
|
||||||
assert!(single.starts_with("= Tag: alpha ="));
|
|
||||||
assert!(single.contains("- [[Home]]"));
|
|
||||||
assert!(single.contains("- [[Work]]"));
|
|
||||||
|
|
||||||
// No-arg variant emits a section per tag.
|
|
||||||
let all = ops::build_tag_links_text(&by_tag, None, "Generated Tags", 1, 0).unwrap();
|
|
||||||
assert!(all.starts_with("= Generated Tags ="));
|
|
||||||
assert!(all.contains("== alpha =="));
|
|
||||||
assert!(all.contains("== beta =="));
|
|
||||||
|
|
||||||
let src = "= Home =\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
|
||||||
assert!(ops::tag_links_edit(
|
|
||||||
src,
|
|
||||||
&doc,
|
|
||||||
&uri,
|
|
||||||
Some("alpha"),
|
|
||||||
&by_tag,
|
|
||||||
true,
|
|
||||||
"Generated Tags",
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
None
|
|
||||||
)
|
|
||||||
.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiRebuildTags — force a full workspace re-index. A rebuild must
|
|
||||||
// reflect the current on-disk tags, dropping stale ones.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_rebuild_tags_reflects_current_state() {
|
|
||||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
|
||||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
|
||||||
idx.upsert(uri.clone(), &parse(":old:\n"));
|
|
||||||
assert_eq!(idx.tags_for("old").len(), 1);
|
|
||||||
|
|
||||||
// Re-index after the page changed on disk.
|
|
||||||
idx.upsert(uri, &parse(":new:\n"));
|
|
||||||
assert!(idx.tags_for("old").is_empty(), "stale tag dropped");
|
|
||||||
assert_eq!(idx.tags_for("new").len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Group 5: HTML export
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
// :Nuwiki2HTML — render the current page to HTML.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_2html_renders_page() {
|
|
||||||
let cfg = wiki_cfg("/wiki");
|
|
||||||
let doc = parse("%title My Page\n= One =\nbody text\n");
|
|
||||||
let html = export::render_page_html(
|
|
||||||
&doc,
|
|
||||||
Some("<title>{{title}}</title><main>{{content}}</main>".into()),
|
|
||||||
"Home",
|
|
||||||
DiaryDate::from_ymd(2026, 5, 12).unwrap(),
|
|
||||||
&cfg.html,
|
|
||||||
Some(".wiki"),
|
|
||||||
HashMap::new(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(html.contains("<title>My Page</title>"));
|
|
||||||
assert!(html.contains("body text"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// :Nuwiki2HTMLBrowse — render then open in the browser. The render output
|
|
||||||
// is identical to :Nuwiki2HTML; the browse step is an editor-side open.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_2html_browse_shares_render_and_is_advertised() {
|
|
||||||
assert!(COMMANDS.contains(&"nuwiki.export.browse"));
|
|
||||||
let cfg = wiki_cfg("/wiki");
|
|
||||||
let doc = parse("= Page =\nhi\n");
|
|
||||||
let html = export::render_page_html(
|
|
||||||
&doc,
|
|
||||||
Some("{{content}}".into()),
|
|
||||||
"Page",
|
|
||||||
DiaryDate::today_utc(),
|
|
||||||
&cfg.html,
|
|
||||||
Some(".wiki"),
|
|
||||||
HashMap::new(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(html.contains("hi"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiAll2HTML[!] — export every page; honour the exclude list and
|
|
||||||
// per-page output paths.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_all2html_maps_output_paths_and_excludes() {
|
|
||||||
let cfg = wiki_cfg("/wiki");
|
|
||||||
assert!(export::output_path_for(&cfg.html, "Home").ends_with("Home.html"));
|
|
||||||
assert!(
|
|
||||||
export::output_path_for(&cfg.html, "diary/2026-05-12").ends_with("diary/2026-05-12.html")
|
|
||||||
);
|
|
||||||
|
|
||||||
let patterns = vec!["_drafts/**".to_string()];
|
|
||||||
assert!(export::is_excluded(&patterns, "_drafts/secret"));
|
|
||||||
assert!(!export::is_excluded(&patterns, "Home"));
|
|
||||||
|
|
||||||
// Both incremental and forced (`!`) variants are advertised.
|
|
||||||
assert!(COMMANDS.contains(&"nuwiki.export.allToHtml"));
|
|
||||||
assert!(COMMANDS.contains(&"nuwiki.export.allToHtmlForce"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// :NuwikiRss — write `rss.xml` summarising recent diary entries.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_rss_writes_feed_file() {
|
|
||||||
let root = std::env::temp_dir().join(format!("nuwiki-cov-rss-{}", std::process::id()));
|
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
|
||||||
let cfg = WikiConfig::from_root(root.clone());
|
|
||||||
|
|
||||||
let entries = vec![
|
|
||||||
diary::DiaryEntry {
|
|
||||||
date: DiaryDate::from_ymd(2026, 5, 10).unwrap(),
|
|
||||||
uri: Url::from_file_path(root.join("diary/2026-05-10.wiki")).unwrap(),
|
|
||||||
},
|
|
||||||
diary::DiaryEntry {
|
|
||||||
date: DiaryDate::from_ymd(2026, 5, 12).unwrap(),
|
|
||||||
uri: Url::from_file_path(root.join("diary/2026-05-12.wiki")).unwrap(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let path = export_ops::write_rss(&cfg, &entries).unwrap();
|
|
||||||
assert!(path.ends_with("rss.xml"));
|
|
||||||
let xml = std::fs::read_to_string(&path).unwrap();
|
|
||||||
assert!(xml.contains("<rss version=\"2.0\""));
|
|
||||||
// Newest entry listed first.
|
|
||||||
let p12 = xml.find("2026-05-12").unwrap();
|
|
||||||
let p10 = xml.find("2026-05-10").unwrap();
|
|
||||||
assert!(p12 < p10);
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Group 6: Other
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
// :NuwikiInstall — install/re-install the `nuwiki-ls` binary. This is an
|
|
||||||
// editor-side action (binary download / cargo build); it is deliberately
|
|
||||||
// NOT an LSP executeCommand, so the server must not advertise it.
|
|
||||||
#[test]
|
|
||||||
fn nuwiki_install_is_editor_only_not_an_lsp_command() {
|
|
||||||
assert!(!COMMANDS.iter().any(|c| c.contains("install")));
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Command-surface cross-check: every documented command that maps to an
|
|
||||||
// executeCommand handler is actually advertised by the server.
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn every_documented_lsp_command_is_advertised() {
|
|
||||||
let expected = [
|
|
||||||
"nuwiki.wiki.openIndex", // :NuwikiIndex
|
|
||||||
"nuwiki.wiki.tabOpenIndex", // :NuwikiTabIndex
|
|
||||||
"nuwiki.wiki.listAll", // :NuwikiUISelect
|
|
||||||
"nuwiki.wiki.gotoPage", // :NuwikiGoto
|
|
||||||
"nuwiki.file.delete", // :NuwikiDeleteFile
|
|
||||||
"nuwiki.diary.openToday", // :NuwikiMakeDiaryNote
|
|
||||||
"nuwiki.diary.openYesterday", // :NuwikiMakeYesterdayDiaryNote
|
|
||||||
"nuwiki.diary.openTomorrow", // :NuwikiMakeTomorrowDiaryNote
|
|
||||||
"nuwiki.diary.openIndex", // :NuwikiDiaryIndex
|
|
||||||
"nuwiki.diary.generateIndex", // :NuwikiDiaryGenerateLinks
|
|
||||||
"nuwiki.diary.next", // :NuwikiDiaryNextDay
|
|
||||||
"nuwiki.diary.prev", // :NuwikiDiaryPrevDay
|
|
||||||
"nuwiki.toc.generate", // :NuwikiTOC
|
|
||||||
"nuwiki.links.generate", // :NuwikiGenerateLinks
|
|
||||||
"nuwiki.workspace.checkLinks", // :NuwikiCheckLinks
|
|
||||||
"nuwiki.tags.search", // :NuwikiSearchTags
|
|
||||||
"nuwiki.tags.generateLinks", // :NuwikiGenerateTagLinks
|
|
||||||
"nuwiki.tags.rebuild", // :NuwikiRebuildTags
|
|
||||||
"nuwiki.export.currentToHtml", // :Nuwiki2HTML
|
|
||||||
"nuwiki.export.browse", // :Nuwiki2HTMLBrowse
|
|
||||||
"nuwiki.export.allToHtml", // :NuwikiAll2HTML
|
|
||||||
"nuwiki.export.rss", // :NuwikiRss
|
|
||||||
];
|
|
||||||
for cmd in expected {
|
|
||||||
assert!(COMMANDS.contains(&cmd), "server must advertise {cmd}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
//! Pure-function tests for the rename helper + the file-delete
|
|
||||||
//! command. The async `Backend::rename` end-to-end (with `read_or_open`
|
|
||||||
//! hitting disk) is exercised by integration tests but covered here
|
|
||||||
//! at the building-block level — same logic, no async client.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use nuwiki_lsp::rename::{build_new_uri, rewrite_wikilink_target};
|
|
||||||
use tower_lsp::lsp_types::Url;
|
|
||||||
|
|
||||||
// ===== rewrite_wikilink_target =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rewrites_plain_wikilink() {
|
|
||||||
let out = rewrite_wikilink_target("[[Old Page]]", "New Page").unwrap();
|
|
||||||
assert_eq!(out, "[[New Page]]");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_description() {
|
|
||||||
let out = rewrite_wikilink_target("[[Old|some text]]", "New").unwrap();
|
|
||||||
assert_eq!(out, "[[New|some text]]");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_anchor() {
|
|
||||||
let out = rewrite_wikilink_target("[[Old#section]]", "New").unwrap();
|
|
||||||
assert_eq!(out, "[[New#section]]");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_anchor_and_description() {
|
|
||||||
let out = rewrite_wikilink_target("[[Old#sec|desc]]", "New").unwrap();
|
|
||||||
assert_eq!(out, "[[New#sec|desc]]");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rewrites_subdirectory_path() {
|
|
||||||
let out = rewrite_wikilink_target("[[old/page]]", "new/subdir/page").unwrap();
|
|
||||||
assert_eq!(out, "[[new/subdir/page]]");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn returns_none_for_non_wikilink_text() {
|
|
||||||
assert_eq!(rewrite_wikilink_target("just text", "X"), None);
|
|
||||||
assert_eq!(rewrite_wikilink_target("[[not closed", "X"), None);
|
|
||||||
assert_eq!(rewrite_wikilink_target("not opened]]", "X"), None);
|
|
||||||
assert_eq!(rewrite_wikilink_target("[]", "X"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== build_new_uri =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn builds_uri_under_root() {
|
|
||||||
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "Page", ".wiki").unwrap();
|
|
||||||
let path = uri.to_file_path().unwrap();
|
|
||||||
assert_eq!(path, PathBuf::from("/tmp/wiki/Page.wiki"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn builds_uri_with_subdirectory() {
|
|
||||||
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "dir/Page", ".wiki").unwrap();
|
|
||||||
let path = uri.to_file_path().unwrap();
|
|
||||||
assert_eq!(path, PathBuf::from("/tmp/wiki/dir/Page.wiki"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn omits_duplicate_extension() {
|
|
||||||
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "Page.wiki", ".wiki").unwrap();
|
|
||||||
let path = uri.to_file_path().unwrap();
|
|
||||||
assert_eq!(path, PathBuf::from("/tmp/wiki/Page.wiki"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ignores_empty_path_segments() {
|
|
||||||
// `//page` should not collapse into a filesystem-absolute path.
|
|
||||||
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "//page", ".wiki").unwrap();
|
|
||||||
let path = uri.to_file_path().unwrap();
|
|
||||||
assert_eq!(path, PathBuf::from("/tmp/wiki/page.wiki"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== executeCommand: nuwiki.file.delete =====
|
|
||||||
//
|
|
||||||
// We don't need a Backend to drive `commands::execute` for the delete
|
|
||||||
// command — it ignores its first arg. The smoke test verifies the
|
|
||||||
// dispatcher + DeleteFile-op packaging.
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn file_delete_returns_workspace_edit_with_delete_op() {
|
|
||||||
use tower_lsp::lsp_types::{DocumentChangeOperation, DocumentChanges, ResourceOp};
|
|
||||||
// Building a real Backend requires a Client. For this unit test we use
|
|
||||||
// `commands::execute` with `Backend` typed as `&_` since the delete
|
|
||||||
// path doesn't touch backend state. Instead we drive the inner logic
|
|
||||||
// by constructing the args inline.
|
|
||||||
//
|
|
||||||
// Easier: shape the test as an integration test over the public
|
|
||||||
// module surface — call `commands::execute` once we expose a thin
|
|
||||||
// builder. We test the JSON-shaped contract using the
|
|
||||||
// edits module directly, since that's what `file_delete` returns.
|
|
||||||
let uri = Url::parse("file:///tmp/note.wiki").unwrap();
|
|
||||||
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
|
|
||||||
b.file_op(nuwiki_lsp::edits::op_delete(uri.clone()));
|
|
||||||
let we = b.build();
|
|
||||||
|
|
||||||
let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else {
|
|
||||||
panic!("expected Operations variant");
|
|
||||||
};
|
|
||||||
assert_eq!(ops.len(), 1);
|
|
||||||
match &ops[0] {
|
|
||||||
DocumentChangeOperation::Op(ResourceOp::Delete(d)) => {
|
|
||||||
assert_eq!(d.uri, uri);
|
|
||||||
}
|
|
||||||
other => panic!("expected DeleteFile op, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
//! Heading level rewriter — `nuwiki.heading.addLevel` /
|
|
||||||
//! `nuwiki.heading.removeLevel`.
|
|
||||||
|
|
||||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
||||||
use nuwiki_core::syntax::SyntaxPlugin;
|
|
||||||
use nuwiki_lsp::commands::ops;
|
|
||||||
use tower_lsp::lsp_types::Url;
|
|
||||||
|
|
||||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
|
||||||
VimwikiSyntax::new().parse(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dummy_uri() -> Url {
|
|
||||||
Url::parse("file:///tmp/note.wiki").unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn one_text_edit(edit: tower_lsp::lsp_types::WorkspaceEdit) -> tower_lsp::lsp_types::TextEdit {
|
|
||||||
let changes = edit.changes.expect("expected changes map");
|
|
||||||
let (_, edits) = changes.into_iter().next().expect("at least one uri");
|
|
||||||
assert_eq!(edits.len(), 1, "expected exactly one text edit");
|
|
||||||
edits.into_iter().next().unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn add_heading_level_promotes_h2_to_h3() {
|
|
||||||
let src = "== Sub ==\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit =
|
|
||||||
ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1).expect("add_level edit");
|
|
||||||
let te = one_text_edit(edit);
|
|
||||||
assert_eq!(te.new_text, "=== Sub ===");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn add_heading_level_caps_at_6() {
|
|
||||||
let src = "====== Deep ======\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1);
|
|
||||||
// No-op because level 6 is the cap; clamp matched current level.
|
|
||||||
assert!(edit.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_heading_level_demotes() {
|
|
||||||
let src = "=== Mid ===\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1)
|
|
||||||
.expect("remove_level edit");
|
|
||||||
let te = one_text_edit(edit);
|
|
||||||
assert_eq!(te.new_text, "== Mid ==");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_heading_level_at_h1_is_noop() {
|
|
||||||
let src = "= Top =\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1);
|
|
||||||
assert!(edit.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn heading_change_returns_none_when_cursor_not_on_heading() {
|
|
||||||
let src = "paragraph\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1);
|
|
||||||
assert!(edit.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rewrite_heading_with_level_keeps_leading_whitespace_outside_span() {
|
|
||||||
// The lexer's heading span starts at the first `=`, not the line's
|
|
||||||
// start, so the leading whitespace that marks a "centered" heading
|
|
||||||
// lives outside the edit range — preserved automatically. The
|
|
||||||
// rewriter's output is just the `=...=` portion.
|
|
||||||
let src = " = Title =\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let h = match &doc.children[0] {
|
|
||||||
nuwiki_core::ast::BlockNode::Heading(h) => h,
|
|
||||||
_ => panic!("expected heading"),
|
|
||||||
};
|
|
||||||
let new = ops::rewrite_heading_with_level(src, h.span, 2).unwrap();
|
|
||||||
assert_eq!(new, "== Title ==");
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
//! Link-related LSP commands.
|
|
||||||
//!
|
|
||||||
//! Covers:
|
|
||||||
//! - `nuwiki.link.pasteWikilink` / `nuwiki.link.pasteUrl` — page-name
|
|
||||||
//! synthesis from a URI and the textual shape the handler inserts
|
|
||||||
//! at the cursor.
|
|
||||||
//! - Wikilink resolution, including the regression where `<CR>` on a
|
|
||||||
//! wikilink to a not-yet-created page must still resolve to a URI
|
|
||||||
//! so the editor can open (and on save create) the future page.
|
|
||||||
//! The resolver itself is private; we exercise the synthesised-path
|
|
||||||
//! semantics via the public config helpers + index lookup.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use nuwiki_core::ast::LinkKind;
|
|
||||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
||||||
use nuwiki_core::syntax::SyntaxPlugin;
|
|
||||||
use nuwiki_lsp::config::WikiConfig;
|
|
||||||
use nuwiki_lsp::index::{page_name_from_uri, WorkspaceIndex};
|
|
||||||
use tower_lsp::lsp_types::Url;
|
|
||||||
|
|
||||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
|
||||||
VimwikiSyntax::new().parse(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== paste helpers =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commands_list_advertises_link_helpers() {
|
|
||||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
|
||||||
for name in [
|
|
||||||
"nuwiki.link.pasteWikilink",
|
|
||||||
"nuwiki.link.pasteUrl",
|
|
||||||
"nuwiki.link.catUrl",
|
|
||||||
// Both GenerateLinks forms must be advertised so coc/vim-lsp accept
|
|
||||||
// them (the scoped form was previously sent but unregistered).
|
|
||||||
"nuwiki.links.generate",
|
|
||||||
"nuwiki.links.generateForPath",
|
|
||||||
] {
|
|
||||||
assert!(names.contains(&name), "missing: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn page_name_for_root_level_page() {
|
|
||||||
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki"));
|
|
||||||
let uri = Url::from_file_path("/tmp/wiki/Home.wiki").unwrap();
|
|
||||||
assert_eq!(page_name_from_uri(&uri, Some(&cfg.root)), "Home");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn page_name_for_subdir_page() {
|
|
||||||
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki"));
|
|
||||||
let uri = Url::from_file_path("/tmp/wiki/diary/2026-05-11.wiki").unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
page_name_from_uri(&uri, Some(&cfg.root)),
|
|
||||||
"diary/2026-05-11"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn paste_url_format_matches_export_output_path_for() {
|
|
||||||
// The URL the `pasteUrl` handler emits is `<name>.html`, where
|
|
||||||
// `<name>` walks path segments. Mirror that derivation and assert
|
|
||||||
// the shape we'd insert at cursor.
|
|
||||||
let name = "diary/2026-05-11";
|
|
||||||
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");
|
|
||||||
assert_eq!(url, "diary/2026-05-11.html");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn paste_wikilink_shape_for_root_page() {
|
|
||||||
// `[[<name>]]` is what the handler inserts at the cursor.
|
|
||||||
let name = "Notes";
|
|
||||||
assert_eq!(format!("[[{name}]]"), "[[Notes]]");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== resolve follow-link to non-existent page =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn link_to_indexed_page_resolves_via_index() {
|
|
||||||
let root = "/tmp/follow-link-1";
|
|
||||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
|
||||||
let target = Url::from_file_path(format!("{root}/Notes.wiki")).unwrap();
|
|
||||||
idx.upsert(target.clone(), &parse("= notes =\n"));
|
|
||||||
|
|
||||||
let resolved = idx.pages_by_name.get("Notes").cloned();
|
|
||||||
assert_eq!(resolved, Some(target));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_page_is_not_in_index() {
|
|
||||||
let root = "/tmp/follow-link-2";
|
|
||||||
let idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
|
||||||
// Empty index — "Missing" isn't there, so the fallback synthesise
|
|
||||||
// path is the only way to follow `[[Missing]]`.
|
|
||||||
assert!(!idx.pages_by_name.contains_key("Missing"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn config_root_plus_path_plus_ext_is_a_valid_uri() {
|
|
||||||
// Sanity: the synthesise logic constructs <root>/<path>.<ext>.
|
|
||||||
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/follow-link-3"));
|
|
||||||
assert_eq!(cfg.file_extension, ".wiki");
|
|
||||||
let target_path = cfg.root.join("Missing.wiki");
|
|
||||||
assert!(Url::from_file_path(&target_path).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wikilink_to_subdir_page_parses_with_slash_in_path() {
|
|
||||||
// Synthesis must walk path segments, not just concatenate.
|
|
||||||
let doc = parse("[[notes/Daily]]\n");
|
|
||||||
let inline = match &doc.children[0] {
|
|
||||||
nuwiki_core::ast::BlockNode::Paragraph(p) => &p.children[0],
|
|
||||||
_ => panic!("expected paragraph"),
|
|
||||||
};
|
|
||||||
let target = match inline {
|
|
||||||
nuwiki_core::ast::InlineNode::WikiLink(w) => &w.target,
|
|
||||||
_ => panic!("expected wikilink"),
|
|
||||||
};
|
|
||||||
assert_eq!(target.kind, LinkKind::Wiki);
|
|
||||||
assert_eq!(target.path.as_deref(), Some("notes/Daily"));
|
|
||||||
}
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
//! Cluster A — list rewriters: `removeDone`, `renumber`,
|
|
||||||
//! `changeSymbol`, `changeLevel`.
|
|
||||||
|
|
||||||
use nuwiki_core::ast::ListSymbol;
|
|
||||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
||||||
use nuwiki_core::syntax::SyntaxPlugin;
|
|
||||||
use nuwiki_lsp::commands::ops;
|
|
||||||
use tower_lsp::lsp_types::{Position as LspPosition, Url};
|
|
||||||
|
|
||||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
|
||||||
VimwikiSyntax::new().parse(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn one_edit_text(edit: tower_lsp::lsp_types::WorkspaceEdit) -> String {
|
|
||||||
let changes = edit.changes.expect("changes map");
|
|
||||||
let (_, edits) = changes.into_iter().next().expect("≥1 uri");
|
|
||||||
edits
|
|
||||||
.into_iter()
|
|
||||||
.map(|e| e.new_text)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uri() -> Url {
|
|
||||||
Url::parse("file:///tmp/list.wiki").unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== parse_symbol + render_marker =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_symbol_accepts_short_and_long_names() {
|
|
||||||
assert_eq!(ops::parse_symbol("-"), Some(ListSymbol::Dash));
|
|
||||||
assert_eq!(ops::parse_symbol("Dash"), Some(ListSymbol::Dash));
|
|
||||||
assert_eq!(ops::parse_symbol("1."), Some(ListSymbol::Numeric));
|
|
||||||
assert_eq!(ops::parse_symbol("Numeric"), Some(ListSymbol::Numeric));
|
|
||||||
assert_eq!(ops::parse_symbol("a)"), Some(ListSymbol::AlphaParen));
|
|
||||||
assert_eq!(ops::parse_symbol("i)"), Some(ListSymbol::RomanParen));
|
|
||||||
assert!(ops::parse_symbol("nope").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_marker_handles_each_symbol() {
|
|
||||||
assert_eq!(ops::render_marker(ListSymbol::Dash, 1), "-");
|
|
||||||
assert_eq!(ops::render_marker(ListSymbol::Star, 1), "*");
|
|
||||||
assert_eq!(ops::render_marker(ListSymbol::Hash, 1), "#");
|
|
||||||
assert_eq!(ops::render_marker(ListSymbol::Numeric, 1), "1.");
|
|
||||||
assert_eq!(ops::render_marker(ListSymbol::Numeric, 4), "4.");
|
|
||||||
assert_eq!(ops::render_marker(ListSymbol::NumericParen, 7), "7)");
|
|
||||||
assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 1), "a)");
|
|
||||||
assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 26), "z)");
|
|
||||||
assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 27), "aa)");
|
|
||||||
assert_eq!(ops::render_marker(ListSymbol::AlphaUpperParen, 2), "B)");
|
|
||||||
assert_eq!(ops::render_marker(ListSymbol::RomanParen, 4), "iv)");
|
|
||||||
assert_eq!(ops::render_marker(ListSymbol::RomanParen, 9), "ix)");
|
|
||||||
assert_eq!(ops::render_marker(ListSymbol::RomanUpperParen, 14), "XIV)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== removeDone =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_done_strips_done_and_rejected_items() {
|
|
||||||
let src = "- [ ] todo\n- [X] done\n- [-] rejected\n- [ ] also todo\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit = ops::remove_done_edit(src, &ast, &uri(), None, None, true).expect("edit");
|
|
||||||
let changes = edit.changes.expect("changes");
|
|
||||||
let edits = &changes[&uri()];
|
|
||||||
assert_eq!(edits.len(), 2, "expected 2 deletions");
|
|
||||||
// Each delete edit replaces a span with empty string.
|
|
||||||
for e in edits {
|
|
||||||
assert_eq!(e.new_text, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_done_leaves_open_items_alone() {
|
|
||||||
let src = "- [ ] todo\n- [.] partial\n- [o] more\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
assert!(ops::remove_done_edit(src, &ast, &uri(), None, None, true).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_done_returns_none_when_no_lists() {
|
|
||||||
let src = "= heading =\nparagraph\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
assert!(ops::remove_done_edit(src, &ast, &uri(), None, None, true).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_done_scoped_by_position_to_current_list() {
|
|
||||||
// With `position` set, the scope is the contiguous list block the
|
|
||||||
// cursor sits in — every done item in that list is removed, not just
|
|
||||||
// the one under the cursor.
|
|
||||||
let src = "- [X] done one\n- [ ] todo\n- [X] done two\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let pos = Some(LspPosition {
|
|
||||||
line: 0,
|
|
||||||
character: 0,
|
|
||||||
});
|
|
||||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, None, true).expect("edit");
|
|
||||||
let edits = &edit.changes.unwrap()[&uri()];
|
|
||||||
assert_eq!(edits.len(), 2, "both done items in the current list match");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_done_position_leaves_other_lists_untouched() {
|
|
||||||
// Two separate list blocks split by a paragraph. Cursor in the first
|
|
||||||
// list removes only that list's done items; the second list's done
|
|
||||||
// item survives.
|
|
||||||
let src = "- [X] done a\n- [ ] todo a\n\nparagraph\n\n- [ ] todo b\n- [X] done b\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let pos = Some(LspPosition {
|
|
||||||
line: 0,
|
|
||||||
character: 0,
|
|
||||||
});
|
|
||||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, None, true).expect("edit");
|
|
||||||
let edits = &edit.changes.unwrap()[&uri()];
|
|
||||||
assert_eq!(edits.len(), 1, "only the first list's done item is removed");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_done_position_cascades_into_sublists() {
|
|
||||||
// The current-list scope includes nested sublists of the block.
|
|
||||||
let src = "- [ ] parent\n - [X] done child\n - [ ] open child\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let pos = Some(LspPosition {
|
|
||||||
line: 0,
|
|
||||||
character: 0,
|
|
||||||
});
|
|
||||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, None, true).expect("edit");
|
|
||||||
let edits = &edit.changes.unwrap()[&uri()];
|
|
||||||
assert_eq!(edits.len(), 1, "the nested done child is removed");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_done_ranged_restricts_to_selected_lines() {
|
|
||||||
// The `:'<,'>VimwikiRemoveDone` form: a 0-indexed inclusive line range
|
|
||||||
// sweeps the whole doc but only deletes done/rejected items inside it.
|
|
||||||
// Lines: 0 `[X]`, 1 `[ ]`, 2 `[X]`, 3 `[-]`. Range 0..=1 keeps only the
|
|
||||||
// line-0 done item; the line-2/3 done+rejected items survive.
|
|
||||||
let src = "- [X] a\n- [ ] b\n- [X] c\n- [-] d\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit = ops::remove_done_edit(src, &ast, &uri(), None, Some((0, 1)), true).expect("edit");
|
|
||||||
let edits = &edit.changes.unwrap()[&uri()];
|
|
||||||
assert_eq!(edits.len(), 1, "only the done item on line 0 is in range");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_done_ranged_returns_none_when_range_has_no_done() {
|
|
||||||
// Range covering only open items → nothing to delete.
|
|
||||||
let src = "- [X] a\n- [ ] b\n- [ ] c\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
assert!(ops::remove_done_edit(src, &ast, &uri(), None, Some((1, 2)), true).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== renumber =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renumber_resequences_numeric_markers() {
|
|
||||||
let src = "5. first\n9. second\n2. third\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit = ops::renumber_edit(src, &ast, &uri(), 0, false, true).expect("edit");
|
|
||||||
let edits = &edit.changes.unwrap()[&uri()];
|
|
||||||
// We expect 3 marker rewrites: 5.→1., 9.→2., 2.→3.
|
|
||||||
assert_eq!(edits.len(), 3);
|
|
||||||
let texts: Vec<&str> = edits.iter().map(|e| e.new_text.as_str()).collect();
|
|
||||||
assert!(texts.contains(&"1."));
|
|
||||||
assert!(texts.contains(&"2."));
|
|
||||||
assert!(texts.contains(&"3."));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renumber_ignores_unordered_lists() {
|
|
||||||
let src = "- a\n- b\n- c\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
assert!(ops::renumber_edit(src, &ast, &uri(), 0, false, true).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renumber_whole_file_walks_every_list() {
|
|
||||||
let src = "5. one\n\n2. two\n9. three\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit = ops::renumber_edit(src, &ast, &uri(), 0, true, true).expect("edit");
|
|
||||||
let edits = &edit.changes.unwrap()[&uri()];
|
|
||||||
// Two separate lists — first has 1 numeric item, second has 2 →
|
|
||||||
// 3 marker rewrites total.
|
|
||||||
assert_eq!(edits.len(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== changeSymbol =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn change_symbol_rewrites_just_the_current_item() {
|
|
||||||
let src = "- a\n- b\n- c\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit =
|
|
||||||
ops::change_symbol_edit(src, &ast, &uri(), 1, ListSymbol::Star, false, true).expect("edit");
|
|
||||||
let edits = &edit.changes.unwrap()[&uri()];
|
|
||||||
assert_eq!(edits.len(), 1);
|
|
||||||
assert_eq!(edits[0].new_text, "*");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn change_symbol_whole_list_rewrites_every_item() {
|
|
||||||
let src = "- a\n- b\n- c\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit = ops::change_symbol_edit(src, &ast, &uri(), 1, ListSymbol::Numeric, true, true)
|
|
||||||
.expect("edit");
|
|
||||||
let edits = &edit.changes.unwrap()[&uri()];
|
|
||||||
assert_eq!(edits.len(), 3);
|
|
||||||
let texts: Vec<&str> = edits.iter().map(|e| e.new_text.as_str()).collect();
|
|
||||||
assert!(texts.contains(&"1."));
|
|
||||||
assert!(texts.contains(&"2."));
|
|
||||||
assert!(texts.contains(&"3."));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== removeCheckbox =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_checkbox_strips_single_item() {
|
|
||||||
let src = "- [ ] task\n- [X] done\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit = ops::remove_checkbox_edit(src, &ast, &uri(), 0, false, true).expect("edit");
|
|
||||||
let edits = &edit.changes.unwrap()[&uri()];
|
|
||||||
assert_eq!(edits.len(), 1);
|
|
||||||
assert_eq!(edits[0].new_text, "");
|
|
||||||
// Removes `[ ] ` — columns 2..6 on the first line.
|
|
||||||
assert_eq!(edits[0].range.start.line, 0);
|
|
||||||
assert_eq!(edits[0].range.start.character, 2);
|
|
||||||
assert_eq!(edits[0].range.end.character, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_checkbox_whole_list_strips_every_item() {
|
|
||||||
let src = "- [ ] a\n- [X] b\n- [-] c\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit = ops::remove_checkbox_edit(src, &ast, &uri(), 0, true, true).expect("edit");
|
|
||||||
let edits = &edit.changes.unwrap()[&uri()];
|
|
||||||
assert_eq!(edits.len(), 3);
|
|
||||||
for e in edits {
|
|
||||||
assert_eq!(e.new_text, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_checkbox_none_without_checkbox() {
|
|
||||||
let src = "- plain item\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
assert!(ops::remove_checkbox_edit(src, &ast, &uri(), 0, false, true).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== changeLevel =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn change_level_indents_single_item() {
|
|
||||||
let src = "- one\n- two\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit =
|
|
||||||
ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true, false, &[]).expect("edit");
|
|
||||||
let edits = &edit.changes.unwrap()[&uri()];
|
|
||||||
assert_eq!(edits.len(), 1);
|
|
||||||
// delta=1 → +2 spaces
|
|
||||||
assert_eq!(edits[0].new_text, " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn change_level_dedents_clamps_to_zero() {
|
|
||||||
let src = "- root\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit = ops::change_level_edit(src, &ast, &uri(), 0, -1, false, true, false, &[]);
|
|
||||||
// Already at column 0 — dedent is a no-op.
|
|
||||||
assert!(edit.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_bullets_rotates_glyph_on_indent() {
|
|
||||||
// With cycle_bullets + bullet_types [-,*,#], indenting a `-` item (depth 0
|
|
||||||
// → 1) rotates the glyph to `*` (and dedenting back to `-`).
|
|
||||||
let bullets: Vec<String> = ["-", "*", "#"].iter().map(|s| s.to_string()).collect();
|
|
||||||
// Put the list well into the document so depth must come from the item's
|
|
||||||
// indentation, not its absolute byte offset (regression guard).
|
|
||||||
let src = "some intro paragraph text here\n\n- one\n- two\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit =
|
|
||||||
ops::change_level_edit(src, &ast, &uri(), 3, 1, false, true, true, &bullets).expect("edit");
|
|
||||||
let texts: Vec<String> = edit.changes.unwrap()[&uri()]
|
|
||||||
.iter()
|
|
||||||
.map(|e| e.new_text.clone())
|
|
||||||
.collect();
|
|
||||||
assert!(texts.contains(&" ".to_string()), "indent edit: {texts:?}");
|
|
||||||
assert!(
|
|
||||||
texts.contains(&"*".to_string()),
|
|
||||||
"glyph rotated to *: {texts:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_bullets_off_leaves_glyph() {
|
|
||||||
let bullets: Vec<String> = ["-", "*", "#"].iter().map(|s| s.to_string()).collect();
|
|
||||||
let src = "- one\n- two\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit = ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true, false, &bullets)
|
|
||||||
.expect("edit");
|
|
||||||
let texts: Vec<String> = edit.changes.unwrap()[&uri()]
|
|
||||||
.iter()
|
|
||||||
.map(|e| e.new_text.clone())
|
|
||||||
.collect();
|
|
||||||
// Only the indent edit; no glyph rewrite.
|
|
||||||
assert!(
|
|
||||||
!texts.iter().any(|t| t == "*"),
|
|
||||||
"no glyph change: {texts:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn change_level_whole_subtree_walks_descendants() {
|
|
||||||
let src = "- parent\n - child a\n - child b\n- sibling\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit =
|
|
||||||
ops::change_level_edit(src, &ast, &uri(), 0, 1, true, true, false, &[]).expect("edit");
|
|
||||||
let edits = &edit.changes.unwrap()[&uri()];
|
|
||||||
// Parent + 2 children get indented. Sibling stays.
|
|
||||||
assert!(edits.len() >= 3, "got {} edits", edits.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== COMMANDS list completeness =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commands_list_includes_cluster_a() {
|
|
||||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
|
||||||
for name in [
|
|
||||||
"nuwiki.list.removeDone",
|
|
||||||
"nuwiki.list.removeCheckbox",
|
|
||||||
"nuwiki.list.renumber",
|
|
||||||
"nuwiki.list.changeSymbol",
|
|
||||||
"nuwiki.list.changeLevel",
|
|
||||||
] {
|
|
||||||
assert!(names.contains(&name), "missing: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Smoke a one_edit_text helper so it's exercised by at least one test.
|
|
||||||
#[test]
|
|
||||||
fn one_edit_text_round_trip() {
|
|
||||||
let src = "- [X] done\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit = ops::remove_done_edit(src, &ast, &uri(), None, None, true).unwrap();
|
|
||||||
let _ = one_edit_text(edit);
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
//! Cluster B — table rewriters: `insert`, `align`, `moveColumn`.
|
|
||||||
|
|
||||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
||||||
use nuwiki_core::syntax::SyntaxPlugin;
|
|
||||||
use nuwiki_lsp::commands::ops;
|
|
||||||
use tower_lsp::lsp_types::Url;
|
|
||||||
|
|
||||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
|
||||||
VimwikiSyntax::new().parse(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uri() -> Url {
|
|
||||||
Url::parse("file:///tmp/tbl.wiki").unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn one_text(edit: tower_lsp::lsp_types::WorkspaceEdit) -> String {
|
|
||||||
let changes = edit.changes.expect("changes");
|
|
||||||
let (_, edits) = changes.into_iter().next().unwrap();
|
|
||||||
edits.into_iter().next().unwrap().new_text
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== render_blank_table =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blank_table_has_header_separator_and_rows() {
|
|
||||||
let out = ops::render_blank_table(3, 2);
|
|
||||||
let lines: Vec<&str> = out.lines().collect();
|
|
||||||
// Header + separator + 2 data rows = 4.
|
|
||||||
assert_eq!(lines.len(), 4);
|
|
||||||
assert_eq!(lines[0], "| | | |");
|
|
||||||
assert_eq!(lines[1], "|--|--|--|");
|
|
||||||
assert_eq!(lines[2], "| | | |");
|
|
||||||
assert_eq!(lines[3], "| | | |");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blank_table_handles_single_cell_grid() {
|
|
||||||
let out = ops::render_blank_table(1, 1);
|
|
||||||
assert_eq!(out, "| |\n|--|\n| |\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== align =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn align_pads_short_cells_to_widest() {
|
|
||||||
let src = "|a|bbb|c|\n|--|--|--|\n|1|2|three|\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit = ops::table_align_edit(src, &ast, &uri(), 0, true, false).expect("edit");
|
|
||||||
let body = one_text(edit);
|
|
||||||
let lines: Vec<&str> = body.lines().collect();
|
|
||||||
eprintln!("rendered table:");
|
|
||||||
for (i, l) in lines.iter().enumerate() {
|
|
||||||
eprintln!(" {i}: {l:?}");
|
|
||||||
}
|
|
||||||
// Column widths: max(a,1)=1 → 1, max(bbb,2)=3, max(c,three)=5.
|
|
||||||
assert_eq!(lines[0], "| a | bbb | c |");
|
|
||||||
// Separator row is repadded with `-` runs sized `width + 2` per
|
|
||||||
// column (the two padding spaces of content rows).
|
|
||||||
assert!(lines[1].starts_with("|---|"), "got: {:?}", lines[1]);
|
|
||||||
assert_eq!(lines[2], "| 1 | 2 | three |");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn align_returns_none_when_cursor_off_table() {
|
|
||||||
let src = "paragraph\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
assert!(ops::table_align_edit(src, &ast, &uri(), 0, true, false).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn table_reduce_last_col_keeps_last_column_minimal() {
|
|
||||||
// With reduce_last_col=true, the last column isn't padded to its content
|
|
||||||
// width — it stays at the minimum (1), so `three` is not surrounded by
|
|
||||||
// alignment padding on the right edge.
|
|
||||||
let src = "|a|bbb|c|\n|--|--|--|\n|1|2|three|\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let edit = ops::table_align_edit(src, &ast, &uri(), 0, true, true).expect("edit");
|
|
||||||
let body = one_text(edit);
|
|
||||||
let lines: Vec<&str> = body.lines().collect();
|
|
||||||
// First two columns still align; the last column stays at width 1 (not 5),
|
|
||||||
// so the header's last cell renders `| c |` not `| c |`.
|
|
||||||
assert_eq!(lines[0], "| a | bbb | c |", "got: {:?}", lines[0]);
|
|
||||||
assert_eq!(lines[2], "| 1 | 2 | three |", "got: {:?}", lines[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== moveColumn =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn move_column_right_swaps_with_neighbour() {
|
|
||||||
let src = "|a|b|c|\n|--|--|--|\n|1|2|3|\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
// Cursor on first column → swap right.
|
|
||||||
let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, 1, true, false).expect("edit");
|
|
||||||
let body = one_text(edit);
|
|
||||||
let lines: Vec<&str> = body.lines().collect();
|
|
||||||
assert!(lines[0].contains("b") && lines[0].contains("a"));
|
|
||||||
// b should come before a after swapping col 0 → 1.
|
|
||||||
let line0 = lines[0];
|
|
||||||
let b_pos = line0.find('b').unwrap();
|
|
||||||
let a_pos = line0.find('a').unwrap();
|
|
||||||
assert!(b_pos < a_pos, "expected b before a; got {line0:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn move_column_left_at_zero_is_noop() {
|
|
||||||
let src = "|a|b|\n|--|--|\n|1|2|\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
// Cursor in column 0; left would be -1 → returns None.
|
|
||||||
let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, -1, true, false);
|
|
||||||
assert!(edit.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn move_column_returns_none_off_table() {
|
|
||||||
let src = "paragraph\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
assert!(ops::table_move_column_edit(src, &ast, &uri(), 0, 0, 1, true, false).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== COMMANDS list =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commands_list_includes_cluster_b() {
|
|
||||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
|
||||||
for name in [
|
|
||||||
"nuwiki.table.insert",
|
|
||||||
"nuwiki.table.align",
|
|
||||||
"nuwiki.table.moveColumn",
|
|
||||||
] {
|
|
||||||
assert!(names.contains(&name), "missing: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
//! Tag-related LSP surface:
|
|
||||||
//! - `WorkspaceIndex` per-page tag lists + reverse `tags_by_name` map,
|
|
||||||
//! stale-tag replacement on re-upsert, and tag cleanup on `remove`.
|
|
||||||
//! - Tag commands: `nuwiki.tags.search`, `nuwiki.tags.generateLinks`,
|
|
||||||
//! `nuwiki.tags.rebuild` — `tag_hits` / `tag_pages_snapshot` /
|
|
||||||
//! `build_tag_links_text` / `tag_links_edit` driven directly so we
|
|
||||||
//! don't need a live handler.
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
||||||
use nuwiki_core::syntax::SyntaxPlugin;
|
|
||||||
use nuwiki_lsp::commands::ops;
|
|
||||||
use nuwiki_lsp::index::WorkspaceIndex;
|
|
||||||
use tower_lsp::lsp_types::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 path = format!("{root}/{name}.wiki");
|
|
||||||
let uri = Url::from_file_path(&path).unwrap();
|
|
||||||
idx.upsert(uri, &parse(src));
|
|
||||||
}
|
|
||||||
idx
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Workspace index: per-page tags + reverse map =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn upsert_populates_per_page_tags() {
|
|
||||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
|
||||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
|
||||||
idx.upsert(uri.clone(), &parse(":alpha:beta:\n= Home =\n"));
|
|
||||||
|
|
||||||
let page = idx.page(&uri).expect("indexed");
|
|
||||||
assert_eq!(page.tags.len(), 2);
|
|
||||||
let names: Vec<_> = page.tags.iter().map(|t| t.name.as_str()).collect();
|
|
||||||
assert!(names.contains(&"alpha"));
|
|
||||||
assert!(names.contains(&"beta"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tags_by_name_aggregates_across_pages() {
|
|
||||||
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(":shared:\n"));
|
|
||||||
idx.upsert(about.clone(), &parse(":shared:other:\n"));
|
|
||||||
|
|
||||||
let shared = idx.tags_for("shared");
|
|
||||||
assert_eq!(shared.len(), 2);
|
|
||||||
let uris: Vec<&Url> = shared.iter().map(|o| &o.uri).collect();
|
|
||||||
assert!(uris.contains(&&home));
|
|
||||||
assert!(uris.contains(&&about));
|
|
||||||
|
|
||||||
assert_eq!(idx.tags_for("other").len(), 1);
|
|
||||||
assert!(idx.tags_for("nonexistent").is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn re_upsert_replaces_stale_tags() {
|
|
||||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
|
||||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
|
||||||
idx.upsert(uri.clone(), &parse(":old:\n"));
|
|
||||||
assert_eq!(idx.tags_for("old").len(), 1);
|
|
||||||
|
|
||||||
idx.upsert(uri.clone(), &parse(":new:\n"));
|
|
||||||
assert!(idx.tags_for("old").is_empty());
|
|
||||||
assert_eq!(idx.tags_for("new").len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_drops_tags_and_reverse_entries() {
|
|
||||||
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:b:\n"));
|
|
||||||
idx.remove(&uri);
|
|
||||||
|
|
||||||
assert!(idx.page(&uri).is_none());
|
|
||||||
assert!(idx.tags_for("a").is_empty());
|
|
||||||
assert!(idx.tags_for("b").is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn heading_scope_tag_indexed_but_not_in_metadata() {
|
|
||||||
// Tag at the third line below a heading is `Heading`-scoped; the
|
|
||||||
// metadata.tags accumulator only collects file-scope tags.
|
|
||||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
|
||||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
|
||||||
let doc = parse("para\n\n\n= H =\n:scoped:\n");
|
|
||||||
idx.upsert(uri.clone(), &doc);
|
|
||||||
|
|
||||||
assert_eq!(idx.tags_for("scoped").len(), 1);
|
|
||||||
assert!(doc.metadata.tags.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== `nuwiki.tags.search` — tag_hits =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tag_hits_empty_query_returns_every_tag() {
|
|
||||||
let idx = build_index(
|
|
||||||
"/tmp/th1",
|
|
||||||
&[
|
|
||||||
("Home", "= Home =\n:work:personal:\n"),
|
|
||||||
("Notes", "= Notes =\n:work:\n"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let hits = ops::tag_hits(&idx, "");
|
|
||||||
let names: Vec<&str> = hits.iter().map(|h| h.name.as_str()).collect();
|
|
||||||
assert!(names.contains(&"work"));
|
|
||||||
assert!(names.contains(&"personal"));
|
|
||||||
// 2 pages tagged with `work`, 1 with `personal` → 3 hits.
|
|
||||||
assert_eq!(hits.len(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tag_hits_substring_filter_case_insensitive() {
|
|
||||||
let idx = build_index(
|
|
||||||
"/tmp/th2",
|
|
||||||
&[("Page", "= Page =\n:release-2026:RoadMap:\n")],
|
|
||||||
);
|
|
||||||
let hits = ops::tag_hits(&idx, "RELEASE");
|
|
||||||
assert_eq!(hits.len(), 1);
|
|
||||||
assert_eq!(hits[0].name, "release-2026");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tag_hits_orders_by_name_then_page() {
|
|
||||||
let idx = build_index(
|
|
||||||
"/tmp/th3",
|
|
||||||
&[
|
|
||||||
("Beta", "= Beta =\n:zeta:\n"),
|
|
||||||
("Alpha", "= Alpha =\n:alpha:\n"),
|
|
||||||
("Gamma", "= Gamma =\n:alpha:\n"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let hits = ops::tag_hits(&idx, "");
|
|
||||||
let pairs: Vec<(String, String)> = hits
|
|
||||||
.iter()
|
|
||||||
.map(|h| (h.name.clone(), h.page.clone()))
|
|
||||||
.collect();
|
|
||||||
// alpha tag comes first; within it, Alpha page before Gamma; then zeta/Beta.
|
|
||||||
assert_eq!(pairs[0], ("alpha".into(), "Alpha".into()));
|
|
||||||
assert_eq!(pairs[1], ("alpha".into(), "Gamma".into()));
|
|
||||||
assert_eq!(pairs[2], ("zeta".into(), "Beta".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tag_hits_serializes_to_expected_json_shape() {
|
|
||||||
let idx = build_index("/tmp/th4", &[("Page", "= Page =\n:release:\n")]);
|
|
||||||
let hits = ops::tag_hits(&idx, "");
|
|
||||||
let v = serde_json::to_value(&hits).unwrap();
|
|
||||||
let row = &v[0];
|
|
||||||
assert_eq!(row["name"], "release");
|
|
||||||
assert!(row["uri"].is_string());
|
|
||||||
assert!(row["range"]["start"]["line"].is_number());
|
|
||||||
assert!(row["page"].is_string());
|
|
||||||
// scope is one of the three string variants.
|
|
||||||
let scope = row["scope"].as_str().unwrap();
|
|
||||||
assert!(
|
|
||||||
["file", "heading", "standalone"].contains(&scope),
|
|
||||||
"{scope}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== `nuwiki.tags.rebuild` — tag_pages_snapshot =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tag_pages_snapshot_deduplicates_pages_per_tag() {
|
|
||||||
// Same page has the tag in both file scope and heading scope —
|
|
||||||
// dedup so the rendered list doesn't show it twice.
|
|
||||||
let idx = build_index(
|
|
||||||
"/tmp/ts1",
|
|
||||||
&[("Home", "= Home =\n:release:\n== Section ==\n:release:\n")],
|
|
||||||
);
|
|
||||||
let snap = ops::tag_pages_snapshot(&idx);
|
|
||||||
assert_eq!(snap.get("release").unwrap().len(), 1);
|
|
||||||
assert_eq!(snap["release"][0], "Home");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tag_pages_snapshot_sorts_pages_alphabetically() {
|
|
||||||
let idx = build_index(
|
|
||||||
"/tmp/ts2",
|
|
||||||
&[
|
|
||||||
("Charlie", "= Charlie =\n:topic:\n"),
|
|
||||||
("Alpha", "= Alpha =\n:topic:\n"),
|
|
||||||
("Bravo", "= Bravo =\n:topic:\n"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let snap = ops::tag_pages_snapshot(&idx);
|
|
||||||
assert_eq!(snap["topic"], vec!["Alpha", "Bravo", "Charlie"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== `nuwiki.tags.generateLinks` — build_tag_links_text =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_tag_links_for_single_tag() {
|
|
||||||
let mut snap = BTreeMap::new();
|
|
||||||
snap.insert("release".to_string(), vec!["Alpha".into(), "Beta".into()]);
|
|
||||||
let out = ops::build_tag_links_text(&snap, Some("release"), "Generated Tags", 1, 0).unwrap();
|
|
||||||
let lines: Vec<&str> = out.lines().collect();
|
|
||||||
assert_eq!(lines[0], "= Tag: release =");
|
|
||||||
assert_eq!(lines[1], "- [[Alpha]]");
|
|
||||||
assert_eq!(lines[2], "- [[Beta]]");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_tag_links_for_missing_tag_returns_none() {
|
|
||||||
let snap = BTreeMap::new();
|
|
||||||
assert!(ops::build_tag_links_text(&snap, Some("ghost"), "Generated Tags", 1, 0).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_tag_links_full_index_groups_by_tag() {
|
|
||||||
let mut snap = BTreeMap::new();
|
|
||||||
snap.insert("a".to_string(), vec!["P1".into()]);
|
|
||||||
snap.insert("b".to_string(), vec!["P2".into(), "P3".into()]);
|
|
||||||
let out = ops::build_tag_links_text(&snap, None, "Generated Tags", 1, 0).unwrap();
|
|
||||||
assert!(out.starts_with("= Generated Tags =\n"));
|
|
||||||
assert!(out.contains("== a =="));
|
|
||||||
assert!(out.contains("== b =="));
|
|
||||||
assert!(out.contains("- [[P1]]"));
|
|
||||||
assert!(out.contains("- [[P2]]"));
|
|
||||||
assert!(out.contains("- [[P3]]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_tag_links_full_index_returns_none_when_no_tags() {
|
|
||||||
let snap = BTreeMap::new();
|
|
||||||
assert!(ops::build_tag_links_text(&snap, None, "Generated Tags", 1, 0).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== `nuwiki.tags.generateLinks` — tag_links_edit (in-buffer rewrite) =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tag_links_edit_inserts_when_section_missing() {
|
|
||||||
let src = "Some content.\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
|
||||||
let mut snap = BTreeMap::new();
|
|
||||||
snap.insert("release".to_string(), vec!["Alpha".into()]);
|
|
||||||
let edit = ops::tag_links_edit(
|
|
||||||
src,
|
|
||||||
&ast,
|
|
||||||
&uri,
|
|
||||||
Some("release"),
|
|
||||||
&snap,
|
|
||||||
true,
|
|
||||||
"Generated Tags",
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.expect("got an edit");
|
|
||||||
let te = &edit.changes.unwrap()[&uri][0];
|
|
||||||
// Insertion at start.
|
|
||||||
assert_eq!(te.range.start, te.range.end);
|
|
||||||
assert!(te.new_text.contains("= Tag: release ="));
|
|
||||||
assert!(te.new_text.contains("[[Alpha]]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tag_links_edit_inserts_at_cursor_line() {
|
|
||||||
// A fresh tags section goes at the cursor line, not the EOF.
|
|
||||||
let src = "= Notes =\nbody\nmore\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
|
||||||
let mut snap = BTreeMap::new();
|
|
||||||
snap.insert("release".to_string(), vec!["Alpha".into()]);
|
|
||||||
let edit = ops::tag_links_edit(
|
|
||||||
src,
|
|
||||||
&ast,
|
|
||||||
&uri,
|
|
||||||
Some("release"),
|
|
||||||
&snap,
|
|
||||||
true,
|
|
||||||
"Generated Tags",
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
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= Tag: release ="),
|
|
||||||
"got: {:?}",
|
|
||||||
te.new_text
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tag_links_edit_replaces_existing_section() {
|
|
||||||
let src = "= Tag: release =\n- [[Stale]]\n\n= Other =\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
|
||||||
let mut snap = BTreeMap::new();
|
|
||||||
snap.insert("release".to_string(), vec!["Fresh".into()]);
|
|
||||||
let edit = ops::tag_links_edit(
|
|
||||||
src,
|
|
||||||
&ast,
|
|
||||||
&uri,
|
|
||||||
Some("release"),
|
|
||||||
&snap,
|
|
||||||
true,
|
|
||||||
"Generated Tags",
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.expect("edit");
|
|
||||||
let te = &edit.changes.unwrap()[&uri][0];
|
|
||||||
assert_eq!(te.range.start.line, 0);
|
|
||||||
assert!(te.new_text.contains("[[Fresh]]"));
|
|
||||||
assert!(!te.new_text.contains("Stale"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tag_links_edit_full_index_replaces_existing_tags_section() {
|
|
||||||
let src = "= Generated Tags =\n- [[old]]\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
|
||||||
let mut snap = BTreeMap::new();
|
|
||||||
snap.insert("alpha".to_string(), vec!["P".into()]);
|
|
||||||
let edit = ops::tag_links_edit(
|
|
||||||
src,
|
|
||||||
&ast,
|
|
||||||
&uri,
|
|
||||||
None,
|
|
||||||
&snap,
|
|
||||||
true,
|
|
||||||
"Generated Tags",
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.expect("edit");
|
|
||||||
let te = &edit.changes.unwrap()[&uri][0];
|
|
||||||
assert!(te.new_text.contains("== alpha =="));
|
|
||||||
assert!(!te.new_text.contains("[[old]]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tag_links_rebuild_edit_only_acts_when_index_present() {
|
|
||||||
let mut snap = BTreeMap::new();
|
|
||||||
snap.insert("a".to_string(), vec!["P".into()]);
|
|
||||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
|
||||||
let without = "= Home =\nbody\n";
|
|
||||||
assert!(
|
|
||||||
ops::tag_links_rebuild_edit(
|
|
||||||
without,
|
|
||||||
&parse(without),
|
|
||||||
&uri,
|
|
||||||
&snap,
|
|
||||||
true,
|
|
||||||
"Generated Tags",
|
|
||||||
1,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
.is_none(),
|
|
||||||
"auto_generate_tags must not insert an index into a page that lacks one"
|
|
||||||
);
|
|
||||||
let with = "= Generated Tags =\n- [[old]]\n";
|
|
||||||
assert!(ops::tag_links_rebuild_edit(
|
|
||||||
with,
|
|
||||||
&parse(with),
|
|
||||||
&uri,
|
|
||||||
&snap,
|
|
||||||
true,
|
|
||||||
"Generated Tags",
|
|
||||||
1,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tag_links_edit_returns_none_for_unknown_tag() {
|
|
||||||
let src = "hi\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
|
||||||
let snap = BTreeMap::new();
|
|
||||||
assert!(ops::tag_links_edit(
|
|
||||||
src,
|
|
||||||
&ast,
|
|
||||||
&uri,
|
|
||||||
Some("ghost"),
|
|
||||||
&snap,
|
|
||||||
true,
|
|
||||||
"Generated Tags",
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
None
|
|
||||||
)
|
|
||||||
.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== smoke: COMMANDS list advertises tag commands =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commands_list_includes_tag_commands() {
|
|
||||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
|
||||||
for name in [
|
|
||||||
"nuwiki.tags.search",
|
|
||||||
"nuwiki.tags.generateLinks",
|
|
||||||
"nuwiki.tags.rebuild",
|
|
||||||
] {
|
|
||||||
assert!(names.contains(&name), "missing: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
//! Task navigation (`nuwiki.list.nextTask`) plus a smoke test that the
|
|
||||||
//! `COMMANDS` registry advertises every list/heading/file handler the
|
|
||||||
//! editor surface relies on.
|
|
||||||
|
|
||||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
||||||
use nuwiki_core::syntax::SyntaxPlugin;
|
|
||||||
use nuwiki_lsp::commands::ops;
|
|
||||||
use tower_lsp::lsp_types::Url;
|
|
||||||
|
|
||||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
|
||||||
VimwikiSyntax::new().parse(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dummy_uri() -> Url {
|
|
||||||
Url::parse("file:///tmp/note.wiki").unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== next_task =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn next_task_returns_first_unfinished() {
|
|
||||||
let src = "- [X] done\n- [ ] todo\n- [ ] later\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found a task");
|
|
||||||
// Should be line 1 (the first [ ]).
|
|
||||||
assert_eq!(loc.range.start.line, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn next_task_wraps_around_when_no_task_after_cursor() {
|
|
||||||
// Only task is on line 0; cursor on line 2 → wraps back.
|
|
||||||
let src = "- [ ] todo\n- [X] done\n- [X] also done\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let loc = ops::next_task(&doc, &dummy_uri(), src, 2, 0, true).expect("found");
|
|
||||||
assert_eq!(loc.range.start.line, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn next_task_returns_none_when_no_open_tasks() {
|
|
||||||
let src = "- [X] a\n- [X] b\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true);
|
|
||||||
assert!(loc.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn next_task_only_counts_items_with_checkboxes() {
|
|
||||||
// Plain items without a `[…]` are ignored — they're not tasks.
|
|
||||||
let src = "- plain\n- [ ] real task\n";
|
|
||||||
let doc = parse(src);
|
|
||||||
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found");
|
|
||||||
assert_eq!(loc.range.start.line, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== smoke: COMMANDS list matches registered handlers =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commands_list_is_complete() {
|
|
||||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
|
||||||
for name in [
|
|
||||||
"nuwiki.file.delete",
|
|
||||||
"nuwiki.list.toggleCheckbox",
|
|
||||||
"nuwiki.list.cycleCheckbox",
|
|
||||||
"nuwiki.list.rejectCheckbox",
|
|
||||||
"nuwiki.list.nextTask",
|
|
||||||
"nuwiki.heading.addLevel",
|
|
||||||
"nuwiki.heading.removeLevel",
|
|
||||||
] {
|
|
||||||
assert!(names.contains(&name), "missing: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,767 +0,0 @@
|
|||||||
//! Diary feature tests:
|
|
||||||
//! - Date primitives, path conventions, nav helpers, IndexedPage diary
|
|
||||||
//! classification.
|
|
||||||
//! - Frequency wiring (daily/weekly/monthly/yearly): per-frequency
|
|
||||||
//! path/URI shape, index recognition, and `next_period` / `prev_period`
|
|
||||||
//! navigation across mixed-frequency entries.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use nuwiki_core::date::{DiaryDate, DiaryFrequency, DiaryPeriod};
|
|
||||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
||||||
use nuwiki_core::syntax::SyntaxPlugin;
|
|
||||||
use nuwiki_lsp::config::WikiConfig;
|
|
||||||
use nuwiki_lsp::diary;
|
|
||||||
use nuwiki_lsp::index::{diary_period_for_uri, WorkspaceIndex};
|
|
||||||
use tower_lsp::lsp_types::Url;
|
|
||||||
|
|
||||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
|
||||||
VimwikiSyntax::new().parse(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wiki_cfg(root: &str) -> WikiConfig {
|
|
||||||
let mut cfg = WikiConfig::from_root(PathBuf::from(root));
|
|
||||||
cfg.diary_rel_path = "diary".into();
|
|
||||||
cfg.diary_index = "diary".into();
|
|
||||||
cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_index_with_entries(root: &str, dates: &[&str]) -> WorkspaceIndex {
|
|
||||||
let mut idx =
|
|
||||||
WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into()));
|
|
||||||
for d in dates {
|
|
||||||
let path = format!("{root}/diary/{d}.wiki");
|
|
||||||
let uri = Url::from_file_path(&path).unwrap();
|
|
||||||
idx.upsert(uri, &parse("= entry =\n"));
|
|
||||||
}
|
|
||||||
idx
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== DiaryDate parser =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_accepts_canonical_iso8601() {
|
|
||||||
let d = DiaryDate::parse("2026-05-11").unwrap();
|
|
||||||
assert_eq!(d.year, 2026);
|
|
||||||
assert_eq!(d.month, 5);
|
|
||||||
assert_eq!(d.day, 11);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_rejects_missing_zero_padding() {
|
|
||||||
assert!(DiaryDate::parse("2026-5-11").is_none());
|
|
||||||
assert!(DiaryDate::parse("2026-05-1").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_rejects_alternate_separators() {
|
|
||||||
assert!(DiaryDate::parse("2026/05/11").is_none());
|
|
||||||
assert!(DiaryDate::parse("2026.05.11").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_rejects_extra_whitespace() {
|
|
||||||
assert!(DiaryDate::parse(" 2026-05-11").is_none());
|
|
||||||
assert!(DiaryDate::parse("2026-05-11 ").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_rejects_invalid_calendar_dates() {
|
|
||||||
assert!(DiaryDate::parse("2026-13-01").is_none(), "month 13");
|
|
||||||
assert!(DiaryDate::parse("2026-02-30").is_none(), "feb 30");
|
|
||||||
assert!(DiaryDate::parse("2026-04-31").is_none(), "april 31");
|
|
||||||
assert!(DiaryDate::parse("2026-00-10").is_none(), "month 0");
|
|
||||||
assert!(DiaryDate::parse("2026-05-00").is_none(), "day 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_accepts_leap_day_in_leap_year() {
|
|
||||||
assert!(DiaryDate::parse("2024-02-29").is_some(), "2024 is leap");
|
|
||||||
assert!(DiaryDate::parse("2023-02-29").is_none(), "2023 not leap");
|
|
||||||
assert!(DiaryDate::parse("2000-02-29").is_some(), "div by 400");
|
|
||||||
assert!(
|
|
||||||
DiaryDate::parse("1900-02-29").is_none(),
|
|
||||||
"div by 100 not 400"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_round_trip() {
|
|
||||||
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
|
||||||
assert_eq!(d.format(), "2026-05-11");
|
|
||||||
assert_eq!(DiaryDate::parse(&d.format()), Some(d));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Date arithmetic =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn next_day_wraps_month_boundary() {
|
|
||||||
let last_of_jan = DiaryDate::from_ymd(2026, 1, 31).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
last_of_jan.next_day(),
|
|
||||||
DiaryDate::from_ymd(2026, 2, 1).unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn next_day_wraps_year_boundary() {
|
|
||||||
let nye = DiaryDate::from_ymd(2025, 12, 31).unwrap();
|
|
||||||
assert_eq!(nye.next_day(), DiaryDate::from_ymd(2026, 1, 1).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prev_day_walks_back_across_month() {
|
|
||||||
let first_of_mar = DiaryDate::from_ymd(2026, 3, 1).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
first_of_mar.prev_day(),
|
|
||||||
DiaryDate::from_ymd(2026, 2, 28).unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prev_day_walks_back_across_leap() {
|
|
||||||
let first_of_mar = DiaryDate::from_ymd(2024, 3, 1).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
first_of_mar.prev_day(),
|
|
||||||
DiaryDate::from_ymd(2024, 2, 29).unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ordering_is_chronological() {
|
|
||||||
let a = DiaryDate::from_ymd(2026, 1, 1).unwrap();
|
|
||||||
let b = DiaryDate::from_ymd(2026, 1, 2).unwrap();
|
|
||||||
let c = DiaryDate::from_ymd(2026, 2, 1).unwrap();
|
|
||||||
let d = DiaryDate::from_ymd(2027, 1, 1).unwrap();
|
|
||||||
assert!(a < b);
|
|
||||||
assert!(b < c);
|
|
||||||
assert!(c < d);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn today_utc_is_a_real_calendar_date() {
|
|
||||||
let t = DiaryDate::today_utc();
|
|
||||||
assert!(t.year >= 2020 && t.year <= 2100);
|
|
||||||
assert!((1..=12).contains(&t.month));
|
|
||||||
assert!((1..=31).contains(&t.day));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== WikiConfig diary paths =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn diary_dir_joins_root_and_rel_path() {
|
|
||||||
let cfg = wiki_cfg("/tmp/wiki");
|
|
||||||
assert_eq!(cfg.diary_dir(), PathBuf::from("/tmp/wiki/diary"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn diary_index_path_uses_diary_index_filename_and_extension() {
|
|
||||||
let cfg = wiki_cfg("/tmp/wiki");
|
|
||||||
assert_eq!(
|
|
||||||
cfg.diary_index_path(),
|
|
||||||
PathBuf::from("/tmp/wiki/diary/diary.wiki")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn diary_path_for_uses_iso_date_as_stem() {
|
|
||||||
let cfg = wiki_cfg("/tmp/wiki");
|
|
||||||
let date = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
cfg.diary_path_for(&date),
|
|
||||||
PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn diary_uri_for_date_is_file_url() {
|
|
||||||
let cfg = wiki_cfg("/tmp/wiki");
|
|
||||||
let date = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
|
||||||
let uri = diary::uri_for_date(&cfg, &date).unwrap();
|
|
||||||
assert!(uri.as_str().ends_with("/diary/2026-05-11.wiki"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn diary_index_uri_resolves() {
|
|
||||||
let cfg = wiki_cfg("/tmp/wiki");
|
|
||||||
let uri = diary::index_uri(&cfg).unwrap();
|
|
||||||
assert!(uri.as_str().ends_with("/diary/diary.wiki"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Index integration: diary_date on IndexedPage =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn upsert_marks_diary_entries() {
|
|
||||||
let root = "/tmp/diary1";
|
|
||||||
let idx = build_index_with_entries(root, &["2026-05-11"]);
|
|
||||||
let uri = Url::from_file_path(format!("{root}/diary/2026-05-11.wiki")).unwrap();
|
|
||||||
let page = idx.page(&uri).expect("page");
|
|
||||||
let date = page.diary_date.expect("diary_date");
|
|
||||||
assert_eq!(date.format(), "2026-05-11");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn upsert_does_not_mark_non_diary_files() {
|
|
||||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary2")))
|
|
||||||
.with_diary_rel_path(Some("diary".into()));
|
|
||||||
let uri = Url::from_file_path("/tmp/diary2/Home.wiki").unwrap();
|
|
||||||
idx.upsert(uri.clone(), &parse("= Home =\n"));
|
|
||||||
assert!(idx.page(&uri).unwrap().diary_date.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn upsert_does_not_mark_dates_outside_diary_subdir() {
|
|
||||||
// A file at root named like a date but not under diary/ → not a
|
|
||||||
// diary entry.
|
|
||||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary3")))
|
|
||||||
.with_diary_rel_path(Some("diary".into()));
|
|
||||||
let uri = Url::from_file_path("/tmp/diary3/2026-05-11.wiki").unwrap();
|
|
||||||
idx.upsert(uri.clone(), &parse("= note =\n"));
|
|
||||||
assert!(idx.page(&uri).unwrap().diary_date.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Diary listing + navigation =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_entries_returns_sorted_ascending() {
|
|
||||||
let idx = build_index_with_entries("/tmp/diary4", &["2026-05-11", "2026-04-30", "2026-05-12"]);
|
|
||||||
let entries = diary::list_entries(&idx);
|
|
||||||
let dates: Vec<String> = entries.iter().map(|e| e.date.format()).collect();
|
|
||||||
assert_eq!(dates, vec!["2026-04-30", "2026-05-11", "2026-05-12"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_entries_filtered_by_year_and_month() {
|
|
||||||
let idx = build_index_with_entries(
|
|
||||||
"/tmp/diary5",
|
|
||||||
&["2025-12-31", "2026-01-01", "2026-05-11", "2026-05-12"],
|
|
||||||
);
|
|
||||||
let may = diary::list_entries_filtered(&idx, Some(2026), Some(5));
|
|
||||||
assert_eq!(may.len(), 2);
|
|
||||||
let y_only = diary::list_entries_filtered(&idx, Some(2026), None);
|
|
||||||
assert_eq!(y_only.len(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn next_entry_finds_strictly_greater() {
|
|
||||||
let idx = build_index_with_entries("/tmp/diary6", &["2026-05-10", "2026-05-12", "2026-05-15"]);
|
|
||||||
let from = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
|
||||||
let next = diary::next_entry(&idx, &from).unwrap();
|
|
||||||
assert_eq!(next.date.format(), "2026-05-12");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn next_entry_none_when_no_future_entries() {
|
|
||||||
let idx = build_index_with_entries("/tmp/diary7", &["2026-05-10"]);
|
|
||||||
let from = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
|
||||||
assert!(diary::next_entry(&idx, &from).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prev_entry_finds_strictly_lesser() {
|
|
||||||
let idx = build_index_with_entries("/tmp/diary8", &["2026-05-10", "2026-05-12", "2026-05-15"]);
|
|
||||||
let from = DiaryDate::from_ymd(2026, 5, 13).unwrap();
|
|
||||||
let prev = diary::prev_entry(&idx, &from).unwrap();
|
|
||||||
assert_eq!(prev.date.format(), "2026-05-12");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Index body generation =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_index_body_is_newest_first_grouped_by_year_month() {
|
|
||||||
let idx = build_index_with_entries(
|
|
||||||
"/tmp/diary9",
|
|
||||||
&["2025-12-31", "2026-05-11", "2026-05-12", "2026-04-30"],
|
|
||||||
);
|
|
||||||
let entries = diary::list_entries(&idx);
|
|
||||||
let body = diary::build_index_body(
|
|
||||||
&entries,
|
|
||||||
"diary",
|
|
||||||
"Diary",
|
|
||||||
diary::DiarySort::Desc,
|
|
||||||
1,
|
|
||||||
&[],
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
let lines: Vec<&str> = body.lines().collect();
|
|
||||||
assert_eq!(lines[0], "= Diary =");
|
|
||||||
// First date in output should be the latest: 2026-05-12
|
|
||||||
let earliest_2026_pos = body.find("2026-05-12").unwrap();
|
|
||||||
let latest_2025_pos = body.find("2025-12-31").unwrap();
|
|
||||||
assert!(earliest_2026_pos < latest_2025_pos, "newer entries first");
|
|
||||||
// Year headings appear
|
|
||||||
assert!(body.contains("== 2026 =="));
|
|
||||||
assert!(body.contains("== 2025 =="));
|
|
||||||
// Month headings appear
|
|
||||||
assert!(body.contains("=== May ==="));
|
|
||||||
assert!(body.contains("=== December ==="));
|
|
||||||
assert!(body.contains("=== April ==="));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_index_body_empty_when_no_entries() {
|
|
||||||
let body = diary::build_index_body(&[], "diary", "Diary", diary::DiarySort::Desc, 1, &[], 0);
|
|
||||||
assert_eq!(body, "= Diary =\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_index_body_ascending_sort_lists_oldest_first() {
|
|
||||||
let idx = build_index_with_entries("/tmp/diarySort", &["2026-05-11", "2026-05-12"]);
|
|
||||||
let entries = diary::list_entries(&idx);
|
|
||||||
let body =
|
|
||||||
diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Asc, 1, &[], 0);
|
|
||||||
let older = body.find("2026-05-11").unwrap();
|
|
||||||
let newer = body.find("2026-05-12").unwrap();
|
|
||||||
assert!(older < newer, "ascending sort lists oldest first");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_index_body_caption_level_shifts_all_headings() {
|
|
||||||
let idx = build_index_with_entries("/tmp/diaryCap", &["2026-05-11"]);
|
|
||||||
let entries = diary::list_entries(&idx);
|
|
||||||
let body = diary::build_index_body(
|
|
||||||
&entries,
|
|
||||||
"diary",
|
|
||||||
"Diary",
|
|
||||||
diary::DiarySort::Desc,
|
|
||||||
2,
|
|
||||||
&[],
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert!(body.contains("== Diary =="), "caption at level 2");
|
|
||||||
assert!(
|
|
||||||
body.contains("=== 2026 ==="),
|
|
||||||
"year nests one below caption"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
body.contains("==== May ===="),
|
|
||||||
"month nests two below caption"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_index_body_clamps_caption_level_to_six() {
|
|
||||||
let idx = build_index_with_entries("/tmp/diaryClamp", &["2026-05-11"]);
|
|
||||||
let entries = diary::list_entries(&idx);
|
|
||||||
// caption_level 6 → year/month would be 7/8 but clamp to 6.
|
|
||||||
let body = diary::build_index_body(
|
|
||||||
&entries,
|
|
||||||
"diary",
|
|
||||||
"Diary",
|
|
||||||
diary::DiarySort::Desc,
|
|
||||||
6,
|
|
||||||
&[],
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert!(body.contains("====== Diary ======"));
|
|
||||||
assert!(!body.contains("======="), "no heading exceeds level 6");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_index_body_uses_custom_diary_months() {
|
|
||||||
let idx = build_index_with_entries("/tmp/diaryMonths", &["2026-05-11", "2026-12-01"]);
|
|
||||||
let entries = diary::list_entries(&idx);
|
|
||||||
// A localized month table (Portuguese) replaces the English labels.
|
|
||||||
let months: Vec<String> = [
|
|
||||||
"Janeiro",
|
|
||||||
"Fevereiro",
|
|
||||||
"Março",
|
|
||||||
"Abril",
|
|
||||||
"Maio",
|
|
||||||
"Junho",
|
|
||||||
"Julho",
|
|
||||||
"Agosto",
|
|
||||||
"Setembro",
|
|
||||||
"Outubro",
|
|
||||||
"Novembro",
|
|
||||||
"Dezembro",
|
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect();
|
|
||||||
let body = diary::build_index_body(
|
|
||||||
&entries,
|
|
||||||
"diary",
|
|
||||||
"Diary",
|
|
||||||
diary::DiarySort::Desc,
|
|
||||||
1,
|
|
||||||
&months,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert!(body.contains("=== Maio ==="), "May → Maio: {body}");
|
|
||||||
assert!(
|
|
||||||
body.contains("=== Dezembro ==="),
|
|
||||||
"December → Dezembro: {body}"
|
|
||||||
);
|
|
||||||
assert!(!body.contains("May"), "English label leaked: {body}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_index_body_falls_back_per_missing_month_slot() {
|
|
||||||
let idx = build_index_with_entries("/tmp/diaryShort", &["2026-05-11"]);
|
|
||||||
let entries = diary::list_entries(&idx);
|
|
||||||
// A too-short table only covers Jan–Mar; May falls back to English.
|
|
||||||
let months: Vec<String> = ["Jan", "Feb", "Mar"]
|
|
||||||
.iter()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect();
|
|
||||||
let body = diary::build_index_body(
|
|
||||||
&entries,
|
|
||||||
"diary",
|
|
||||||
"Diary",
|
|
||||||
diary::DiarySort::Desc,
|
|
||||||
1,
|
|
||||||
&months,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
body.contains("=== May ==="),
|
|
||||||
"missing slot → English: {body}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_index_body_clamps_negative_caption_level_to_zero() {
|
|
||||||
let idx = build_index_with_entries("/tmp/diaryNeg", &["2026-05-11"]);
|
|
||||||
let entries = diary::list_entries(&idx);
|
|
||||||
// vimwiki allows diary_caption_level = -1; nuwiki clamps it to 0 (same as
|
|
||||||
// a caption_level of 0: caption h1, year h1, month h2).
|
|
||||||
let body = diary::build_index_body(
|
|
||||||
&entries,
|
|
||||||
"diary",
|
|
||||||
"Diary",
|
|
||||||
diary::DiarySort::Desc,
|
|
||||||
-1,
|
|
||||||
&[],
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert!(body.contains("= Diary ="), "caption at level 1: {body}");
|
|
||||||
assert!(body.contains("= 2026 ="), "year at level 1: {body}");
|
|
||||||
assert!(body.contains("== May =="), "month at level 2: {body}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_index_body_round_trip() {
|
|
||||||
let cfg = wiki_cfg("/tmp/diaryA");
|
|
||||||
let idx = build_index_with_entries("/tmp/diaryA", &["2026-05-11"]);
|
|
||||||
let body = diary::render_index_body(&cfg, &idx);
|
|
||||||
assert!(body.contains("= Diary ="));
|
|
||||||
assert!(body.contains("[[diary/2026-05-11]]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_index_body_honours_custom_header_sort_and_caption() {
|
|
||||||
let mut cfg = wiki_cfg("/tmp/diaryCustom");
|
|
||||||
cfg.diary_header = "Journal".into();
|
|
||||||
cfg.diary_sort = "asc".into();
|
|
||||||
cfg.diary_caption_level = 2;
|
|
||||||
let idx = build_index_with_entries("/tmp/diaryCustom", &["2026-05-11", "2026-05-12"]);
|
|
||||||
let body = diary::render_index_body(&cfg, &idx);
|
|
||||||
assert!(
|
|
||||||
body.contains("== Journal =="),
|
|
||||||
"custom header at caption level 2"
|
|
||||||
);
|
|
||||||
let older = body.find("2026-05-11").unwrap();
|
|
||||||
let newer = body.find("2026-05-12").unwrap();
|
|
||||||
assert!(older < newer, "asc sort honoured");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== is_in_diary =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_in_diary_recognises_subdir_paths() {
|
|
||||||
let cfg = wiki_cfg("/tmp/wiki");
|
|
||||||
assert!(diary::is_in_diary(
|
|
||||||
&cfg,
|
|
||||||
&PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki")
|
|
||||||
));
|
|
||||||
assert!(!diary::is_in_diary(
|
|
||||||
&cfg,
|
|
||||||
&PathBuf::from("/tmp/wiki/Home.wiki")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Serde =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn diary_entry_serializes_to_date_string_and_uri() {
|
|
||||||
let uri = Url::parse("file:///tmp/wiki/diary/2026-05-11.wiki").unwrap();
|
|
||||||
let entry = diary::DiaryEntry {
|
|
||||||
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
|
||||||
uri,
|
|
||||||
};
|
|
||||||
let v = serde_json::to_value(&entry).unwrap();
|
|
||||||
assert_eq!(v["date"], "2026-05-11");
|
|
||||||
assert!(v["uri"].as_str().unwrap().contains("2026-05-11.wiki"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== COMMANDS list completeness =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commands_list_includes_diary_entries() {
|
|
||||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
|
||||||
for name in [
|
|
||||||
"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",
|
|
||||||
] {
|
|
||||||
assert!(names.contains(&name), "missing: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Config wire format =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wiki_config_defaults_match_vimwiki() {
|
|
||||||
let cfg = WikiConfig::empty();
|
|
||||||
assert_eq!(cfg.diary_rel_path, "diary");
|
|
||||||
assert_eq!(cfg.diary_index, "diary");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wiki_config_from_root_uses_default_diary_paths() {
|
|
||||||
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/x"));
|
|
||||||
assert_eq!(cfg.diary_rel_path, "diary");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn config_from_json_accepts_custom_diary_paths() {
|
|
||||||
use nuwiki_lsp::config::config_from_json;
|
|
||||||
let cfg = config_from_json(serde_json::json!({
|
|
||||||
"wikis": [
|
|
||||||
{ "root": "/tmp/p", "diary_rel_path": "journal", "diary_index": "index" },
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
assert_eq!(cfg.wikis[0].diary_rel_path, "journal");
|
|
||||||
assert_eq!(cfg.wikis[0].diary_index, "index");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Frequency wiring (daily / weekly / monthly / yearly) =====
|
|
||||||
|
|
||||||
fn cfg(root: &str, frequency: &str) -> WikiConfig {
|
|
||||||
let mut c = WikiConfig::from_root(PathBuf::from(root));
|
|
||||||
c.diary_rel_path = "diary".into();
|
|
||||||
c.diary_frequency = frequency.into();
|
|
||||||
c
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn frequency_helper_picks_up_config_value() {
|
|
||||||
assert_eq!(cfg("/tmp", "daily").frequency(), DiaryFrequency::Daily);
|
|
||||||
assert_eq!(cfg("/tmp", "weekly").frequency(), DiaryFrequency::Weekly);
|
|
||||||
assert_eq!(cfg("/tmp", "monthly").frequency(), DiaryFrequency::Monthly);
|
|
||||||
assert_eq!(cfg("/tmp", "yearly").frequency(), DiaryFrequency::Yearly);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn diary_path_for_period_picks_right_stem_per_frequency() {
|
|
||||||
let c = cfg("/wiki", "daily");
|
|
||||||
let day = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap());
|
|
||||||
let wk = DiaryPeriod::Week {
|
|
||||||
iso_year: 2026,
|
|
||||||
iso_week: 19,
|
|
||||||
};
|
|
||||||
let mo = DiaryPeriod::Month {
|
|
||||||
year: 2026,
|
|
||||||
month: 5,
|
|
||||||
};
|
|
||||||
let yr = DiaryPeriod::Year { year: 2026 };
|
|
||||||
assert_eq!(
|
|
||||||
c.diary_path_for_period(&day),
|
|
||||||
PathBuf::from("/wiki/diary/2026-05-12.wiki")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
c.diary_path_for_period(&wk),
|
|
||||||
PathBuf::from("/wiki/diary/2026-W19.wiki")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
c.diary_path_for_period(&mo),
|
|
||||||
PathBuf::from("/wiki/diary/2026-05.wiki")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
c.diary_path_for_period(&yr),
|
|
||||||
PathBuf::from("/wiki/diary/2026.wiki")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn uri_for_period_produces_file_url_per_frequency() {
|
|
||||||
let c = cfg("/wiki", "weekly");
|
|
||||||
let wk = DiaryPeriod::Week {
|
|
||||||
iso_year: 2026,
|
|
||||||
iso_week: 19,
|
|
||||||
};
|
|
||||||
let u = diary::uri_for_period(&c, &wk).unwrap();
|
|
||||||
assert!(
|
|
||||||
u.as_str().ends_with("/wiki/diary/2026-W19.wiki"),
|
|
||||||
"got: {u}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_index_with(entries: &[&str]) -> WorkspaceIndex {
|
|
||||||
let root = PathBuf::from("/wiki");
|
|
||||||
let mut idx = WorkspaceIndex::new(Some(root.clone())).with_diary_rel_path(Some("diary".into()));
|
|
||||||
for stem in entries {
|
|
||||||
let path = format!("/wiki/diary/{stem}.wiki");
|
|
||||||
let uri = Url::from_file_path(&path).unwrap();
|
|
||||||
let ast = VimwikiSyntax::new().parse("= entry =\n");
|
|
||||||
idx.upsert(uri, &ast);
|
|
||||||
}
|
|
||||||
idx
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn index_recognises_all_four_frequency_stems() {
|
|
||||||
let idx = make_index_with(&["2026-05-12", "2026-W19", "2026-05", "2026"]);
|
|
||||||
for (stem, expected) in [
|
|
||||||
(
|
|
||||||
"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 }),
|
|
||||||
] {
|
|
||||||
let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap();
|
|
||||||
let page = idx.page(&uri).expect(stem);
|
|
||||||
assert_eq!(
|
|
||||||
page.diary_period,
|
|
||||||
Some(expected),
|
|
||||||
"stem {stem} should index as {expected:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn index_only_sets_diary_date_for_daily_entries() {
|
|
||||||
let idx = make_index_with(&["2026-05-12", "2026-W19"]);
|
|
||||||
let daily = Url::from_file_path("/wiki/diary/2026-05-12.wiki").unwrap();
|
|
||||||
let weekly = Url::from_file_path("/wiki/diary/2026-W19.wiki").unwrap();
|
|
||||||
assert!(idx.page(&daily).unwrap().diary_date.is_some());
|
|
||||||
assert!(idx.page(&weekly).unwrap().diary_date.is_none());
|
|
||||||
assert!(idx.page(&weekly).unwrap().diary_period.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn diary_period_for_uri_rejects_paths_outside_diary_dir() {
|
|
||||||
// Non-diary path with a date-looking stem — should NOT count.
|
|
||||||
let uri = Url::from_file_path("/wiki/notes/2026-05-12.wiki").unwrap();
|
|
||||||
let root = PathBuf::from("/wiki");
|
|
||||||
assert!(diary_period_for_uri(&uri, Some(&root), Some("diary")).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn next_prev_period_navigate_at_the_same_frequency() {
|
|
||||||
// Mix entries at different frequencies. Navigation should stay
|
|
||||||
// within the same flavour as the pivot.
|
|
||||||
let idx = make_index_with(&[
|
|
||||||
"2026-W18",
|
|
||||||
"2026-W19",
|
|
||||||
"2026-W20", // weekly
|
|
||||||
"2026-04",
|
|
||||||
"2026-05",
|
|
||||||
"2026-06", // monthly
|
|
||||||
"2026-05-10",
|
|
||||||
"2026-05-12", // daily
|
|
||||||
]);
|
|
||||||
let pivot_w = DiaryPeriod::Week {
|
|
||||||
iso_year: 2026,
|
|
||||||
iso_week: 19,
|
|
||||||
};
|
|
||||||
let next_w = diary::next_period(&idx, &pivot_w).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
next_w.period,
|
|
||||||
DiaryPeriod::Week {
|
|
||||||
iso_year: 2026,
|
|
||||||
iso_week: 20
|
|
||||||
}
|
|
||||||
);
|
|
||||||
let prev_w = diary::prev_period(&idx, &pivot_w).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
prev_w.period,
|
|
||||||
DiaryPeriod::Week {
|
|
||||||
iso_year: 2026,
|
|
||||||
iso_week: 18
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
let pivot_m = DiaryPeriod::Month {
|
|
||||||
year: 2026,
|
|
||||||
month: 5,
|
|
||||||
};
|
|
||||||
let next_m = diary::next_period(&idx, &pivot_m).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
next_m.period,
|
|
||||||
DiaryPeriod::Month {
|
|
||||||
year: 2026,
|
|
||||||
month: 6
|
|
||||||
}
|
|
||||||
);
|
|
||||||
let prev_m = diary::prev_period(&idx, &pivot_m).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
prev_m.period,
|
|
||||||
DiaryPeriod::Month {
|
|
||||||
year: 2026,
|
|
||||||
month: 4
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn next_prev_period_returns_none_past_the_edge() {
|
|
||||||
let idx = make_index_with(&["2026-W19"]);
|
|
||||||
let only = DiaryPeriod::Week {
|
|
||||||
iso_year: 2026,
|
|
||||||
iso_week: 19,
|
|
||||||
};
|
|
||||||
assert!(diary::next_period(&idx, &only).is_none());
|
|
||||||
assert!(diary::prev_period(&idx, &only).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn diary_period_for_uri_handles_each_frequency_under_diary_dir() {
|
|
||||||
let root = PathBuf::from("/wiki");
|
|
||||||
for (stem, expected) in [
|
|
||||||
(
|
|
||||||
"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 }),
|
|
||||||
] {
|
|
||||||
let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap();
|
|
||||||
let got = diary_period_for_uri(&uri, Some(&root), Some("diary"));
|
|
||||||
assert_eq!(got, Some(expected), "stem {stem}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
//! LSP folding-range provider.
|
|
||||||
//!
|
|
||||||
//! Drives `folding::folding_ranges` directly. The handler wiring is
|
|
||||||
//! shallow — it just calls into this function — so we verify the
|
|
||||||
//! shapes here and trust the integration via the LSP test harness in
|
|
||||||
//! `lsp_helpers.rs`.
|
|
||||||
|
|
||||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
||||||
use nuwiki_core::syntax::SyntaxPlugin;
|
|
||||||
use nuwiki_lsp::folding;
|
|
||||||
|
|
||||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
|
||||||
VimwikiSyntax::new().parse(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_headings_no_lists_emits_no_folds() {
|
|
||||||
let src = "just text\non two lines\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
|
||||||
assert!(folds.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn single_heading_folds_to_end_of_document() {
|
|
||||||
let src = "= Title =\nbody line 1\nbody line 2\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
|
||||||
assert_eq!(folds.len(), 1);
|
|
||||||
assert_eq!(folds[0].start_line, 0);
|
|
||||||
// body line 2 is line 2; trailing newline pushes line_count to 4 so
|
|
||||||
// last_line == 3 (the empty line after the final newline).
|
|
||||||
assert!(folds[0].end_line >= 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sibling_headings_each_get_their_own_fold() {
|
|
||||||
let src = "= One =\nbody\n= Two =\nbody2\n= Three =\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
|
||||||
let heading_folds: Vec<_> = folds
|
|
||||||
.iter()
|
|
||||||
.filter(|f| f.start_line == 0 || f.start_line == 2 || f.start_line == 4)
|
|
||||||
.collect();
|
|
||||||
assert_eq!(heading_folds.len(), 3, "got: {folds:?}");
|
|
||||||
// One closes before Two
|
|
||||||
assert_eq!(heading_folds[0].end_line, 1);
|
|
||||||
// Two closes before Three
|
|
||||||
assert_eq!(heading_folds[1].end_line, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nested_heading_folds_inside_parent() {
|
|
||||||
let src = "= Outer =\nintro\n== Inner ==\nbody\n= Other =\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
|
||||||
// Outer fold spans up to the line before `= Other =` (line 4).
|
|
||||||
let outer = folds
|
|
||||||
.iter()
|
|
||||||
.find(|f| f.start_line == 0)
|
|
||||||
.expect("outer fold");
|
|
||||||
assert_eq!(outer.end_line, 3);
|
|
||||||
// Inner fold is bounded by the next h1 too (since the only following
|
|
||||||
// heading is h1, which has level <= 2).
|
|
||||||
let inner = folds
|
|
||||||
.iter()
|
|
||||||
.find(|f| f.start_line == 2)
|
|
||||||
.expect("inner fold");
|
|
||||||
assert_eq!(inner.end_line, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn top_level_list_gets_its_own_fold() {
|
|
||||||
let src = "before\n- item one\n- item two\n- item three\nafter\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
|
||||||
let list_fold = folds.iter().find(|f| f.start_line == 1);
|
|
||||||
assert!(list_fold.is_some(), "expected a list fold: {folds:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn single_line_blocks_are_not_folded() {
|
|
||||||
let src = "= Solo =\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
|
||||||
// A heading on the only line of the document covers nothing
|
|
||||||
// foldable, so it's omitted.
|
|
||||||
let none_for_solo = folds
|
|
||||||
.iter()
|
|
||||||
.all(|f| !(f.start_line == 0 && f.end_line == 0));
|
|
||||||
assert!(none_for_solo, "got: {folds:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fold_kind_is_region() {
|
|
||||||
use tower_lsp::lsp_types::FoldingRangeKind;
|
|
||||||
let src = "= H =\nbody\n";
|
|
||||||
let ast = parse(src);
|
|
||||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
|
||||||
assert!(!folds.is_empty());
|
|
||||||
assert_eq!(folds[0].kind, Some(FoldingRangeKind::Region));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn line_count_matches_intuitive_definition() {
|
|
||||||
assert_eq!(folding::line_count(""), 0);
|
|
||||||
assert_eq!(folding::line_count("one"), 1);
|
|
||||||
assert_eq!(folding::line_count("one\n"), 2); // trailing newline → virtual empty line
|
|
||||||
assert_eq!(folding::line_count("one\ntwo"), 2);
|
|
||||||
assert_eq!(folding::line_count("one\ntwo\n"), 3);
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
//! Tests for the pure helpers that the LSP backend uses to translate
|
|
||||||
//! between `nuwiki-core` ASTs and the LSP wire format. The async
|
|
||||||
//! request-handling loop is exercised at integration time by the
|
|
||||||
//! navigation tests, so this file stays focused on conversion correctness.
|
|
||||||
|
|
||||||
use nuwiki_core::ast::{
|
|
||||||
inline::InlineNode, BlockNode, BlockquoteNode, DocumentNode, ErrorNode, ParagraphNode,
|
|
||||||
Position, Span, TextNode,
|
|
||||||
};
|
|
||||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
||||||
use nuwiki_core::syntax::SyntaxPlugin;
|
|
||||||
use nuwiki_lsp::{ast_diagnostics, headings_to_symbols, to_lsp_position, to_lsp_range};
|
|
||||||
use tower_lsp::lsp_types::{DiagnosticSeverity, DocumentSymbolResponse, SymbolKind};
|
|
||||||
|
|
||||||
// ===== Position / Range conversion =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn position_passthrough_in_utf8_mode() {
|
|
||||||
let pos = Position {
|
|
||||||
line: 3,
|
|
||||||
column: 7,
|
|
||||||
offset: 0,
|
|
||||||
};
|
|
||||||
let lsp = to_lsp_position(&pos, "ignored", true);
|
|
||||||
assert_eq!(lsp.line, 3);
|
|
||||||
assert_eq!(lsp.character, 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn position_translates_to_utf16_for_multibyte_chars() {
|
|
||||||
// "héllo" in UTF-8: h(1) é(2 bytes) l(1) l(1) o(1) — total 6 bytes.
|
|
||||||
// In UTF-16 each char is one code unit (BMP), so byte col 3 ("after é")
|
|
||||||
// maps to UTF-16 char col 2.
|
|
||||||
let line = "héllo\nworld\n";
|
|
||||||
let pos = Position {
|
|
||||||
line: 0,
|
|
||||||
column: 3,
|
|
||||||
offset: 3,
|
|
||||||
};
|
|
||||||
let lsp = to_lsp_position(&pos, line, false);
|
|
||||||
assert_eq!(lsp.line, 0);
|
|
||||||
assert_eq!(lsp.character, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn position_handles_astral_plane_in_utf16() {
|
|
||||||
// 🦀 is U+1F980, two UTF-16 code units (surrogate pair), four UTF-8 bytes.
|
|
||||||
let text = "🦀x\n";
|
|
||||||
// After the crab + x, byte col is 5, UTF-16 col is 3 (2 surrogates + 1 BMP).
|
|
||||||
let pos = Position {
|
|
||||||
line: 0,
|
|
||||||
column: 5,
|
|
||||||
offset: 5,
|
|
||||||
};
|
|
||||||
let lsp = to_lsp_position(&pos, text, false);
|
|
||||||
assert_eq!(lsp.character, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn range_passthrough_in_utf8_mode() {
|
|
||||||
let span = Span::new(
|
|
||||||
Position {
|
|
||||||
line: 0,
|
|
||||||
column: 0,
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
Position {
|
|
||||||
line: 0,
|
|
||||||
column: 5,
|
|
||||||
offset: 5,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let r = to_lsp_range(&span, "hello\n", true);
|
|
||||||
assert_eq!(r.start.line, 0);
|
|
||||||
assert_eq!(r.start.character, 0);
|
|
||||||
assert_eq!(r.end.character, 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Diagnostics =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ast_diagnostics_yields_one_per_error_node() {
|
|
||||||
let span_a = Span::new(
|
|
||||||
Position {
|
|
||||||
line: 0,
|
|
||||||
column: 0,
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
Position {
|
|
||||||
line: 0,
|
|
||||||
column: 4,
|
|
||||||
offset: 4,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let span_b = Span::new(
|
|
||||||
Position {
|
|
||||||
line: 2,
|
|
||||||
column: 0,
|
|
||||||
offset: 10,
|
|
||||||
},
|
|
||||||
Position {
|
|
||||||
line: 2,
|
|
||||||
column: 3,
|
|
||||||
offset: 13,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let doc = DocumentNode {
|
|
||||||
span: Span::default(),
|
|
||||||
metadata: Default::default(),
|
|
||||||
children: vec![
|
|
||||||
BlockNode::Error(ErrorNode {
|
|
||||||
span: span_a,
|
|
||||||
raw: "first".into(),
|
|
||||||
message: "first parse failure".into(),
|
|
||||||
}),
|
|
||||||
BlockNode::Paragraph(ParagraphNode {
|
|
||||||
span: Span::default(),
|
|
||||||
children: vec![InlineNode::Text(TextNode {
|
|
||||||
span: Span::default(),
|
|
||||||
content: "ok".into(),
|
|
||||||
})],
|
|
||||||
}),
|
|
||||||
BlockNode::Error(ErrorNode {
|
|
||||||
span: span_b,
|
|
||||||
raw: "second".into(),
|
|
||||||
message: "second parse failure".into(),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
let diags = ast_diagnostics(&doc, "ignored", true);
|
|
||||||
assert_eq!(diags.len(), 2);
|
|
||||||
assert_eq!(diags[0].message, "first parse failure");
|
|
||||||
assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
|
|
||||||
assert_eq!(diags[0].source.as_deref(), Some("nuwiki"));
|
|
||||||
assert_eq!(diags[0].range.start.line, 0);
|
|
||||||
assert_eq!(diags[1].range.start.line, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ast_diagnostics_descends_into_blockquotes() {
|
|
||||||
let span = Span::new(
|
|
||||||
Position {
|
|
||||||
line: 1,
|
|
||||||
column: 2,
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
Position {
|
|
||||||
line: 1,
|
|
||||||
column: 6,
|
|
||||||
offset: 4,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let inner_error = BlockNode::Error(ErrorNode {
|
|
||||||
span,
|
|
||||||
raw: "x".into(),
|
|
||||||
message: "nested".into(),
|
|
||||||
});
|
|
||||||
let bq = BlockNode::Blockquote(BlockquoteNode {
|
|
||||||
span: Span::default(),
|
|
||||||
children: vec![inner_error],
|
|
||||||
});
|
|
||||||
let doc = DocumentNode {
|
|
||||||
span: Span::default(),
|
|
||||||
metadata: Default::default(),
|
|
||||||
children: vec![bq],
|
|
||||||
};
|
|
||||||
|
|
||||||
let diags = ast_diagnostics(&doc, "", true);
|
|
||||||
assert_eq!(diags.len(), 1);
|
|
||||||
assert_eq!(diags[0].message, "nested");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Document symbols =====
|
|
||||||
|
|
||||||
fn parse(src: &str) -> DocumentNode {
|
|
||||||
VimwikiSyntax::new().parse(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn flat_outline_when_all_headings_same_level() {
|
|
||||||
let doc = parse("= A =\n= B =\n= C =\n");
|
|
||||||
let syms = headings_to_symbols(&doc, "", true);
|
|
||||||
assert_eq!(syms.len(), 3);
|
|
||||||
let names: Vec<&str> = syms.iter().map(|s| s.name.as_str()).collect();
|
|
||||||
assert_eq!(names, vec!["A", "B", "C"]);
|
|
||||||
for s in &syms {
|
|
||||||
assert!(s.children.is_none() || s.children.as_ref().unwrap().is_empty());
|
|
||||||
assert_eq!(s.kind, SymbolKind::STRING);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nested_outline_follows_heading_levels() {
|
|
||||||
let src = "\
|
|
||||||
= Top =
|
|
||||||
== Sub A ==
|
|
||||||
=== Deeper ===
|
|
||||||
== Sub B ==
|
|
||||||
= Other =
|
|
||||||
";
|
|
||||||
let doc = parse(src);
|
|
||||||
let syms = headings_to_symbols(&doc, "", true);
|
|
||||||
assert_eq!(syms.len(), 2);
|
|
||||||
|
|
||||||
// Top contains Sub A and Sub B; Sub A contains Deeper.
|
|
||||||
let top = &syms[0];
|
|
||||||
assert_eq!(top.name, "Top");
|
|
||||||
let top_kids = top.children.as_ref().expect("Top should have children");
|
|
||||||
assert_eq!(top_kids.len(), 2);
|
|
||||||
assert_eq!(top_kids[0].name, "Sub A");
|
|
||||||
assert_eq!(top_kids[1].name, "Sub B");
|
|
||||||
|
|
||||||
let sub_a_kids = top_kids[0]
|
|
||||||
.children
|
|
||||||
.as_ref()
|
|
||||||
.expect("Sub A should have children");
|
|
||||||
assert_eq!(sub_a_kids.len(), 1);
|
|
||||||
assert_eq!(sub_a_kids[0].name, "Deeper");
|
|
||||||
|
|
||||||
assert_eq!(syms[1].name, "Other");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn heading_title_strips_inline_markers() {
|
|
||||||
let doc = parse("= Hello *world* and `code` =\n");
|
|
||||||
let syms = headings_to_symbols(&doc, "", true);
|
|
||||||
assert_eq!(syms.len(), 1);
|
|
||||||
assert_eq!(syms[0].name, "Hello world and code");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_heading_falls_back_to_placeholder_name() {
|
|
||||||
let doc = parse("= =\n");
|
|
||||||
let syms = headings_to_symbols(&doc, "", true);
|
|
||||||
if let Some(s) = syms.first() {
|
|
||||||
assert!(!s.name.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== End-to-end through the registry =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn outline_symbols_match_registry_dispatch() {
|
|
||||||
let src = "= A =\n== B ==\n";
|
|
||||||
let plugin = VimwikiSyntax::new();
|
|
||||||
let doc = plugin.parse(src);
|
|
||||||
let syms = headings_to_symbols(&doc, src, true);
|
|
||||||
assert_eq!(syms.len(), 1);
|
|
||||||
assert_eq!(syms[0].name, "A");
|
|
||||||
let kids = syms[0].children.as_ref().unwrap();
|
|
||||||
assert_eq!(kids[0].name, "B");
|
|
||||||
// Sanity: symbols can be wrapped in DocumentSymbolResponse::Nested.
|
|
||||||
let _resp = DocumentSymbolResponse::Nested(syms);
|
|
||||||
}
|
|
||||||
@@ -1,834 +0,0 @@
|
|||||||
//! HTML export commands.
|
|
||||||
//!
|
|
||||||
//! Drives the pure `export::*` helpers directly. The side-effecting
|
|
||||||
//! `export_ops::write_page` / `write_rss` paths get exercised via a
|
|
||||||
//! per-test temp dir so we know writes land where they should and
|
|
||||||
//! the rendered HTML contains the substituted vars.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use nuwiki_core::date::DiaryDate;
|
|
||||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
||||||
use nuwiki_core::syntax::SyntaxPlugin;
|
|
||||||
use nuwiki_lsp::config::{HtmlConfig, WikiConfig};
|
|
||||||
use nuwiki_lsp::export;
|
|
||||||
|
|
||||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
|
||||||
VimwikiSyntax::new().parse(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cfg(root: &str) -> WikiConfig {
|
|
||||||
WikiConfig::from_root(PathBuf::from(root))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn temp_root(suffix: &str) -> PathBuf {
|
|
||||||
let p = std::env::temp_dir().join(format!("nuwiki-p17-{suffix}-{}", std::process::id()));
|
|
||||||
let _ = std::fs::remove_dir_all(&p);
|
|
||||||
std::fs::create_dir_all(&p).unwrap();
|
|
||||||
p
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== HtmlConfig defaults =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn html_config_defaults_match_vimwiki() {
|
|
||||||
let c = HtmlConfig::default();
|
|
||||||
assert_eq!(c.template_default, "default");
|
|
||||||
assert_eq!(c.template_ext, ".tpl");
|
|
||||||
assert_eq!(c.template_date_format, "%Y-%m-%d");
|
|
||||||
assert_eq!(c.css_name, "style.css");
|
|
||||||
assert!(!c.auto_export);
|
|
||||||
assert!(!c.html_filename_parameterization);
|
|
||||||
assert!(c.exclude_files.is_empty());
|
|
||||||
assert_eq!(c.html_header_numbering, 0); // upstream default: numbering off
|
|
||||||
assert!(c.html_header_numbering_sym.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn html_config_from_root_uses_underscore_dirs() {
|
|
||||||
let h = HtmlConfig::from_root(Path::new("/tmp/wiki"));
|
|
||||||
assert_eq!(h.html_path, PathBuf::from("/tmp/wiki/_html"));
|
|
||||||
assert_eq!(h.template_path, PathBuf::from("/tmp/wiki/_templates"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wiki_config_picks_up_html_paths() {
|
|
||||||
let c = cfg("/tmp/x");
|
|
||||||
assert_eq!(c.html.html_path, PathBuf::from("/tmp/x/_html"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn config_from_json_honours_explicit_html_keys() {
|
|
||||||
use nuwiki_lsp::config::config_from_json;
|
|
||||||
let c = config_from_json(serde_json::json!({
|
|
||||||
"wikis": [{
|
|
||||||
"root": "/tmp/y",
|
|
||||||
"html_path": "/tmp/y/site",
|
|
||||||
"template_default": "post",
|
|
||||||
"template_ext": ".tmpl",
|
|
||||||
"auto_export": true,
|
|
||||||
"html_filename_parameterization": true,
|
|
||||||
"exclude_files": ["_drafts/**"],
|
|
||||||
}],
|
|
||||||
}));
|
|
||||||
let h = &c.wikis[0].html;
|
|
||||||
assert_eq!(h.html_path, PathBuf::from("/tmp/y/site"));
|
|
||||||
assert_eq!(h.template_default, "post");
|
|
||||||
assert_eq!(h.template_ext, ".tmpl");
|
|
||||||
assert!(h.auto_export);
|
|
||||||
assert!(h.html_filename_parameterization);
|
|
||||||
assert_eq!(h.exclude_files, vec!["_drafts/**"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== format_date =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_date_strftime_subset() {
|
|
||||||
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
|
||||||
assert_eq!(export::format_date(&d, "%Y-%m-%d"), "2026-05-11");
|
|
||||||
assert_eq!(export::format_date(&d, "%Y"), "2026");
|
|
||||||
assert_eq!(export::format_date(&d, "%m/%d/%Y"), "05/11/2026");
|
|
||||||
assert_eq!(export::format_date(&d, "literal text"), "literal text");
|
|
||||||
assert_eq!(
|
|
||||||
export::format_date(&d, "100%% done"),
|
|
||||||
"100% done",
|
|
||||||
"%% escapes to a literal %"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_date_unknown_specifier_passes_through() {
|
|
||||||
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
|
||||||
// We support Y/m/d/%; %H is not supported and should pass through verbatim.
|
|
||||||
assert_eq!(export::format_date(&d, "%H:00"), "%H:00");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== compute_root_path =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_root_path_is_empty_at_top_level() {
|
|
||||||
assert_eq!(export::compute_root_path("Home"), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_root_path_adds_one_dotdot_per_subdir() {
|
|
||||||
assert_eq!(export::compute_root_path("diary/2026-05-11"), "../");
|
|
||||||
assert_eq!(export::compute_root_path("a/b/c"), "../../");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== output_path_for =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn output_path_for_basic() {
|
|
||||||
let c = cfg("/tmp/x");
|
|
||||||
assert_eq!(
|
|
||||||
export::output_path_for(&c.html, "Home"),
|
|
||||||
PathBuf::from("/tmp/x/_html/Home.html")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn output_path_for_nested_preserves_subdirs() {
|
|
||||||
let c = cfg("/tmp/x");
|
|
||||||
assert_eq!(
|
|
||||||
export::output_path_for(&c.html, "diary/2026-05-11"),
|
|
||||||
PathBuf::from("/tmp/x/_html/diary/2026-05-11.html")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn output_path_for_slugifies_only_the_filename_when_enabled() {
|
|
||||||
let mut c = cfg("/tmp/x");
|
|
||||||
c.html.html_filename_parameterization = true;
|
|
||||||
let out = export::output_path_for(&c.html, "Notes/My Page!");
|
|
||||||
// subdir keeps casing, filename becomes lowercase + url-safe.
|
|
||||||
assert_eq!(out, PathBuf::from("/tmp/x/_html/notes/my-page.html"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== is_excluded =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_excluded_matches_globstar() {
|
|
||||||
let patterns = vec!["_drafts/**".into()];
|
|
||||||
assert!(export::is_excluded(&patterns, "_drafts/foo"));
|
|
||||||
assert!(export::is_excluded(&patterns, "_drafts/x/y"));
|
|
||||||
assert!(!export::is_excluded(&patterns, "notes/x"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_excluded_matches_simple_glob() {
|
|
||||||
let patterns = vec!["*.tmp".into()];
|
|
||||||
assert!(export::is_excluded(&patterns, "foo.tmp"));
|
|
||||||
assert!(!export::is_excluded(&patterns, "foo.wiki"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_excluded_matches_question_mark() {
|
|
||||||
let patterns = vec!["??".into()];
|
|
||||||
assert!(export::is_excluded(&patterns, "ab"));
|
|
||||||
assert!(!export::is_excluded(&patterns, "abc"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_excluded_no_match_returns_false() {
|
|
||||||
let patterns = vec!["foo".into()];
|
|
||||||
assert!(!export::is_excluded(&patterns, "bar"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== TOC HTML =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_toc_html_produces_nested_lists() {
|
|
||||||
let ast = parse("= One =\n== Two ==\n=== Three ===\n= Four =\n");
|
|
||||||
let html = export::build_toc_html(&ast);
|
|
||||||
assert!(html.contains("ul class=\"toc\""));
|
|
||||||
assert!(html.contains(">One<"));
|
|
||||||
assert!(html.contains(">Two<"));
|
|
||||||
assert!(html.contains(">Three<"));
|
|
||||||
assert!(html.contains(">Four<"));
|
|
||||||
// Verify the nesting: Two should be inside an inner <ul> after One.
|
|
||||||
let one_idx = html.find(">One<").unwrap();
|
|
||||||
let two_idx = html.find(">Two<").unwrap();
|
|
||||||
let three_idx = html.find(">Three<").unwrap();
|
|
||||||
assert!(one_idx < two_idx && two_idx < three_idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_toc_html_empty_for_no_headings() {
|
|
||||||
let ast = parse("Just a paragraph.\n");
|
|
||||||
assert!(export::build_toc_html(&ast).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_toc_html_escapes_special_chars_in_title() {
|
|
||||||
let ast = parse("= A & B <ok> =\n");
|
|
||||||
let html = export::build_toc_html(&ast);
|
|
||||||
assert!(html.contains("A & B <ok>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== template_for =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn template_for_prefers_metadata_template() {
|
|
||||||
let c = cfg("/tmp/x");
|
|
||||||
let ast = parse("%template post\n= Hi =\n");
|
|
||||||
let src = export::template_for(&c.html, &ast);
|
|
||||||
assert_eq!(
|
|
||||||
src.primary,
|
|
||||||
Some(PathBuf::from("/tmp/x/_templates/post.tpl"))
|
|
||||||
);
|
|
||||||
assert_eq!(src.fallback, PathBuf::from("/tmp/x/_templates/default.tpl"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn template_for_falls_back_when_no_directive() {
|
|
||||||
let c = cfg("/tmp/x");
|
|
||||||
let ast = parse("= Hi =\n");
|
|
||||||
let src = export::template_for(&c.html, &ast);
|
|
||||||
assert!(src.primary.is_none());
|
|
||||||
assert_eq!(src.fallback, PathBuf::from("/tmp/x/_templates/default.tpl"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== render_page_html =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_page_html_passes_through_valid_html_tags() {
|
|
||||||
// `<sub>`/`<kbd>` are in the default valid_html_tags → verbatim; `<script>`
|
|
||||||
// is not → escaped.
|
|
||||||
let ast = parse("H<sub>2</sub>O press <kbd>Ctrl</kbd> <script>x</script>\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("H<sub>2</sub>O"), "sub verbatim: {html}");
|
|
||||||
assert!(html.contains("<kbd>Ctrl</kbd>"), "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 <br>.
|
|
||||||
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("<br"), "default joins with spaces: {def}");
|
|
||||||
assert!(def.contains("alpha beta"), "para space: {def}");
|
|
||||||
|
|
||||||
// false/false: soft breaks become <br /> 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<br />beta"), "para <br>: {br}");
|
|
||||||
assert!(
|
|
||||||
br.contains("one<br />two") || br.contains("one<br /> two"),
|
|
||||||
"list <br>: {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>{{title}}</title><body>{{content}}</body><footer>{{date}}</footer>";
|
|
||||||
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("<title>My Page</title>"));
|
|
||||||
assert!(html.contains("<footer>2026-05-11</footer>"));
|
|
||||||
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(
|
|
||||||
"<div id=\"Intro\"><h1 id=\"Intro\" class=\"header\"><a href=\"#Intro\">1. Intro</a></h1></div>"
|
|
||||||
),
|
|
||||||
"got: {html}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
html.contains(
|
|
||||||
"<div id=\"Intro-Background\"><h2 id=\"Background\" class=\"header\"><a href=\"#Intro-Background\">1.1. Background</a></h2></div>"
|
|
||||||
),
|
|
||||||
"got: {html}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
html.contains(
|
|
||||||
"<div id=\"Intro-Methods\"><h2 id=\"Methods\" class=\"header\"><a href=\"#Intro-Methods\">1.2. Methods</a></h2></div>"
|
|
||||||
),
|
|
||||||
"got: {html}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
html.contains(
|
|
||||||
"<div id=\"Results\"><h1 id=\"Results\" class=\"header\"><a href=\"#Results\">2. Results</a></h1></div>"
|
|
||||||
),
|
|
||||||
"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(
|
|
||||||
"<div id=\"Title\"><h1 id=\"Title\" class=\"header\"><a href=\"#Title\">Title</a></h1></div>"
|
|
||||||
),
|
|
||||||
"h1 unnumbered, got: {html}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
html.contains(
|
|
||||||
"<div id=\"Title-Section\"><h2 id=\"Section\" class=\"header\"><a href=\"#Title-Section\">1 Section</a></h2></div>"
|
|
||||||
),
|
|
||||||
"got: {html}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
html.contains(
|
|
||||||
"<div id=\"Title-Section-Sub\"><h3 id=\"Sub\" class=\"header\"><a href=\"#Title-Section-Sub\">1.1 Sub</a></h3></div>"
|
|
||||||
),
|
|
||||||
"got: {html}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_page_html_same_page_anchor_uses_current_filename() {
|
|
||||||
// vimwiki parity (#4): `[[#Section]]` resolves to `<page>.html#Section`,
|
|
||||||
// not a bare `#Section`, where <page> 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("<a href=\"index.html#Contents\">"),
|
|
||||||
"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(
|
|
||||||
"<div id=\"Intro\"><h1 id=\"Intro\" class=\"header\"><a href=\"#Intro\">Intro</a></h1></div>"
|
|
||||||
),
|
|
||||||
"got: {html}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
html.contains(
|
|
||||||
"<div id=\"Intro-Sub\"><h2 id=\"Sub\" class=\"header\"><a href=\"#Intro-Sub\">Sub</a></h2></div>"
|
|
||||||
),
|
|
||||||
"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>%title%</title>\
|
|
||||||
<link href=\"%root_path%%wiki_css%\">\
|
|
||||||
<body>%content%</body><footer>%date%</footer>";
|
|
||||||
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("<title>My Page</title>"), "title: {html}");
|
|
||||||
assert!(html.contains("body text"), "content: {html}");
|
|
||||||
assert!(
|
|
||||||
html.contains("href=\"../style.css\""),
|
|
||||||
"root_path+css: {html}"
|
|
||||||
);
|
|
||||||
assert!(html.contains("<footer>2026-05-11</footer>"), "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"),
|
|
||||||
"<x>{{title}}|{{content}}</x>",
|
|
||||||
)
|
|
||||||
.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("<x>T|"));
|
|
||||||
assert!(body.ends_with("</x>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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("<?xml"));
|
|
||||||
assert!(xml.contains("<title>Test Wiki</title>"));
|
|
||||||
// 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("<link>file:///tmp/diary/2026-05-11.wiki</link>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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("<link>https://example.com/wiki/diary/diary.html</link>"),
|
|
||||||
"channel link: {xml}"
|
|
||||||
);
|
|
||||||
assert!(xml.contains("<link>https://example.com/wiki/diary/2026-05-11.html</link>"));
|
|
||||||
// guid is the bare date, isPermaLink=false.
|
|
||||||
assert!(
|
|
||||||
xml.contains("<guid isPermaLink=\"false\">2026-05-11</guid>"),
|
|
||||||
"guid: {xml}"
|
|
||||||
);
|
|
||||||
// atom:link self + a pubDate are present.
|
|
||||||
assert!(xml.contains("rel=\"self\""), "atom:link: {xml}");
|
|
||||||
assert!(xml.contains("<pubDate>"), "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"), "<h1>Home</h1>").unwrap();
|
|
||||||
// An orphan (no source) → pruned.
|
|
||||||
std::fs::write(html.join("Ghost.html"), "<h1>Ghost</h1>").unwrap();
|
|
||||||
// A user_htmls-protected orphan → kept.
|
|
||||||
std::fs::write(html.join("keep.html"), "<p>manual</p>").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<DiaryEntry> = (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("<item>").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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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": "<u>__CONTENT__</u>",
|
|
||||||
}],
|
|
||||||
}));
|
|
||||||
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, "<u>__CONTENT__</u>");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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);
|
|
||||||
}
|
|
||||||
@@ -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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<N>:` / `wn.<Name>:` 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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 <CR> 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());
|
|
||||||
}
|
|
||||||
@@ -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<String> = 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);
|
|
||||||
}
|
|
||||||
+44
-90
@@ -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.
|
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
|
## Repository Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
nuwiki/
|
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
|
├── plugin/ # Universal Vim/Neovim entry point
|
||||||
│ └── nuwiki.vim
|
│ └── nuwiki.vim
|
||||||
│
|
│
|
||||||
@@ -47,7 +18,7 @@ nuwiki/
|
|||||||
│ ├── init.lua
|
│ ├── init.lua
|
||||||
│ ├── config.lua
|
│ ├── config.lua
|
||||||
│ ├── lsp.lua
|
│ ├── lsp.lua
|
||||||
│ └── install.lua # Binary download / build-from-source logic
|
│ └── install.lua # Binary download logic
|
||||||
│
|
│
|
||||||
├── autoload/ # Lazy-loaded VimL (Vim compat layer)
|
├── autoload/ # Lazy-loaded VimL (Vim compat layer)
|
||||||
│ └── nuwiki/
|
│ └── nuwiki/
|
||||||
@@ -92,26 +63,21 @@ nuwiki/
|
|||||||
└── release.yaml # Cross-compile + release on v* tag
|
└── 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.
|
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:
|
||||||
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.
|
```bash
|
||||||
|
git clone https://code.gfran.co/gffranco/nuwiki-rs
|
||||||
|
cd nuwiki-rs
|
||||||
|
cargo build --release -p nuwiki-ls
|
||||||
|
```
|
||||||
|
|
||||||
## Build & Installation
|
## Build & Installation
|
||||||
|
|
||||||
### Prerequisites
|
### 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+)
|
- For editor integration: Vim 9+ or Neovim 0.5+ with an LSP client (vim-lsp recommended for Vim, built-in for Neovim 0.11+)
|
||||||
|
- The `nuwiki-ls` binary is downloaded automatically; no Rust toolchain required unless building from source
|
||||||
### 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`.
|
|
||||||
|
|
||||||
### Installation via Plugin Managers
|
### Installation via Plugin Managers
|
||||||
|
|
||||||
@@ -143,37 +109,34 @@ call dein#add('gffranco/nuwiki', {
|
|||||||
```bash
|
```bash
|
||||||
git clone https://code.gfran.co/gffranco/nuwiki ~/.vim/pack/gffranco/start/nuwiki
|
git clone https://code.gfran.co/gffranco/nuwiki ~/.vim/pack/gffranco/start/nuwiki
|
||||||
cd ~/.vim/pack/gffranco/start/nuwiki
|
cd ~/.vim/pack/gffranco/start/nuwiki
|
||||||
cargo build --release -p nuwiki-ls
|
vim -e -s -c "source scripts/download_bin.vim" -c "q"
|
||||||
mkdir -p bin && ln -s ../target/release/nuwiki-ls bin/nuwiki-ls
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Plain Vim users also need an LSP client — vim-lsp is recommended.
|
Plain Vim users also need an LSP client — vim-lsp is recommended.
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
### Testing
|
### Testing (Editor Layer)
|
||||||
```bash
|
|
||||||
# Run all tests (workspace)
|
|
||||||
cargo test --workspace
|
|
||||||
|
|
||||||
# Run tests for a specific crate
|
```bash
|
||||||
cargo test -p nuwiki-core
|
# Run editor tests (shell + Lua harnesses in development/tests/)
|
||||||
cargo test -p nuwiki-lsp
|
./development/tests/test-keymaps.sh
|
||||||
cargo test -p nuwiki-ls
|
./development/tests/test-calendar.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Linting & Formatting
|
For Rust-level tests, see the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository:
|
||||||
```bash
|
|
||||||
# Check formatting
|
|
||||||
cargo fmt -- --check
|
|
||||||
|
|
||||||
# Run Clippy
|
```bash
|
||||||
|
# In the nuwiki-rs repo
|
||||||
|
cargo test --workspace
|
||||||
cargo clippy --workspace -- -D warnings
|
cargo clippy --workspace -- -D warnings
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running the LSP Server Manually (for debugging)
|
### Running the LSP Server Manually (for debugging)
|
||||||
|
|
||||||
|
Build from [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build and run the binary
|
|
||||||
cargo run -p nuwiki-ls -- --help
|
cargo run -p nuwiki-ls -- --help
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -210,44 +173,35 @@ This verifies:
|
|||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
|
|
||||||
- The core library (`nuwiki-core`) is completely editor-agnostic and can be tested independently.
|
- The Rust LSP server is in the separate [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository.
|
||||||
- The LSP layer (`nuwiki-lsp`) depends only on the core and tower-lsp.
|
- This plugin repo contains only the editor client layer (VimL/Lua).
|
||||||
- The binary crate (`nuwiki-ls`) is intentionally thin — it just initializes and runs the LSP server.
|
|
||||||
- Editor layers (VimL/Lua) contain zero logic — they are pure wiring only.
|
- Editor layers (VimL/Lua) contain zero logic — they are pure wiring only.
|
||||||
- All syntax-specific code lives in `nuwiki-core/src/syntax/<syntax>/` making it easy to add new syntaxes (e.g., markdown) in the future.
|
- All syntax-specific code lives in the Rust LSP server's `nuwiki-core` crate.
|
||||||
- The project uses a workspace Cargo.toml with resolver v2 (edition 2021).
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone this plugin repository:
|
||||||
```bash
|
```bash
|
||||||
git clone https://code.gfran.co/gffranco/nuwiki
|
git clone https://code.gfran.co/gffranco/nuwiki
|
||||||
cd nuwiki
|
cd nuwiki
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Build the LSP binary:
|
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.
|
||||||
```bash
|
|
||||||
cargo build --release -p nuwiki-ls
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Set up a test wiki directory:
|
3. Open a .wiki file and verify:
|
||||||
```bash
|
- Syntax highlighting works
|
||||||
mkdir -p ~/test-wiki
|
- `:VimwikiIndex` opens the index page
|
||||||
echo "# Test Wiki" > ~/test-wiki/index.wiki
|
- 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).
|
To modify the LSP server itself, clone the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository and build the binary from source.
|
||||||
|
|
||||||
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`
|
|
||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
The project uses Gitea Actions:
|
This repo (editor client) uses Gitea Actions:
|
||||||
- CI (`.gitea/workflows/ci.yaml`): Runs on every push/PR — checks formatting, Clippy, and runs tests.
|
- CI (`.gitea/workflows/ci.yaml`): Runs editor tests on every push/PR.
|
||||||
- Release (`.gitea/workflows/release.yaml`): Triggers on `v*` tag — cross-compiles for Linux targets and creates a Gitea release.
|
|
||||||
|
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
|
## License
|
||||||
|
|
||||||
|
|||||||
+19
-46
@@ -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
|
nuwiki is a vimwiki-compatible Vim/Neovim plugin backed by a Rust language
|
||||||
server. The editor layers are thin clients; all parsing, navigation, and
|
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 |
|
| Property | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| License | Dual MIT / Apache-2.0 |
|
| 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+ |
|
| 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
|
Editor client (VimL / Lua) — pure wiring, no logic
|
||||||
│ LSP over stdio
|
│ LSP over stdio
|
||||||
▼
|
▼
|
||||||
nuwiki-lsp — protocol bridge, executeCommand, document store, config
|
nuwiki-ls (nuwiki-rs) — Rust LSP server: protocol bridge, executeCommand,
|
||||||
│
|
document store, config, parser, renderer
|
||||||
▼
|
|
||||||
nuwiki-core — lexer → parser → AST → renderer (no editor deps)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
- The VimL and Lua layers contain no logic — every command body issues a
|
||||||
`workspace/executeCommand` or a built-in LSP request.
|
`workspace/executeCommand` or a built-in LSP request.
|
||||||
|
|
||||||
### Repository layout
|
### 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)
|
plugin/nuwiki.vim # universal entry point (detects Vim vs Neovim)
|
||||||
lua/nuwiki/ # Neovim layer: init, config, lsp, keymaps, commands,
|
lua/nuwiki/ # Neovim layer: init, config, lsp, keymaps, commands,
|
||||||
# folding, install
|
# folding, install
|
||||||
@@ -72,12 +47,15 @@ ftdetect/ ftplugin/ syntax/ # filetype detection, buffer setup, fallback HL
|
|||||||
doc/nuwiki.txt # :help
|
doc/nuwiki.txt # :help
|
||||||
scripts/download_bin.vim # plugin-runtime binary download (build hooks)
|
scripts/download_bin.vim # plugin-runtime binary download (build hooks)
|
||||||
development/ # dev-only tooling + this document (not shipped)
|
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
|
### AST
|
||||||
|
|
||||||
@@ -199,9 +177,8 @@ raw and external URLs are never diagnosed.
|
|||||||
Pre-built binaries are published as Gitea release assets
|
Pre-built binaries are published as Gitea release assets
|
||||||
(`nuwiki-ls-{version}-{target}.tar.gz`) and fetched at install time by
|
(`nuwiki-ls-{version}-{target}.tar.gz`) and fetched at install time by
|
||||||
`lua/nuwiki/install.lua` (Neovim) or `scripts/download_bin.vim` (Vim build
|
`lua/nuwiki/install.lua` (Neovim) or `scripts/download_bin.vim` (Vim build
|
||||||
hooks), installed to `{plugin_dir}/bin/nuwiki-ls`. Falls back to
|
hooks), installed to `{plugin_dir}/bin/nuwiki-ls`. The binary is downloaded
|
||||||
`cargo build --release` when download fails or
|
from the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) releases.
|
||||||
`g:nuwiki_build_from_source = 1`.
|
|
||||||
|
|
||||||
`plugin/nuwiki.vim` dispatches: Neovim → `require('nuwiki').setup()`; Vim →
|
`plugin/nuwiki.vim` dispatches: Neovim → `require('nuwiki').setup()`; Vim →
|
||||||
`nuwiki#lsp#start()` (prefers `vim-lsp`, falls back to `coc.nvim`). On Neovim
|
`nuwiki#lsp#start()` (prefers `vim-lsp`, falls back to `coc.nvim`). On Neovim
|
||||||
@@ -323,16 +300,12 @@ provides a no-LSP fallback):
|
|||||||
|
|
||||||
| Job | Command |
|
| 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) |
|
| 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
|
`.gitea/workflows/release.yaml` (in [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs))
|
||||||
Linux targets (gnu/musl × x86_64/aarch64) via `cross`, packages `.tar.gz`, and
|
triggers on `v*` tags: cross-compiles the four Linux targets (gnu/musl × x86_64/aarch64)
|
||||||
creates a Gitea release using `RELEASE_TOKEN`. macOS and Windows binaries are
|
via `cross`, packages `.tar.gz`, and creates a Gitea release using `RELEASE_TOKEN`.
|
||||||
built manually and attached to the same release. crates.io publishing is not
|
|
||||||
wired up.
|
|
||||||
|
|
||||||
A change is semver-breaking if it alters `nuwiki-core` AST nodes, the
|
A change is semver-breaking if it alters `nuwiki-core` AST nodes, the
|
||||||
`SyntaxPlugin`/`Renderer` traits, the user-config schema, or removes an LSP
|
`SyntaxPlugin`/`Renderer` traits, the user-config schema, or removes an LSP
|
||||||
|
|||||||
+27
-13
@@ -10,7 +10,7 @@
|
|||||||
# Exports:
|
# Exports:
|
||||||
# REPO_ROOT / DEV_DIR / WIKI_DIR — common paths
|
# REPO_ROOT / DEV_DIR / WIKI_DIR — common paths
|
||||||
# log — prefixed status line
|
# 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
|
# write_sample_wiki DIR — lay down the feature-showcase wiki
|
||||||
# reset_dirs DIR... — wipe + recreate owned state dirs
|
# reset_dirs DIR... — wipe + recreate owned state dirs
|
||||||
# seed_wiki — (re)seed WIKI_DIR fresh each run; never
|
# 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() {
|
ensure_binary() {
|
||||||
local bin="$REPO_ROOT/bin/nuwiki-ls"
|
local bin="$REPO_ROOT/bin/nuwiki-ls"
|
||||||
local built="$REPO_ROOT/target/release/nuwiki-ls"
|
if [[ -x "$bin" ]]; then
|
||||||
if [[ "${NUWIKI_DEV_SKIP_BUILD:-0}" == "1" ]]; then
|
log "binary ready: $bin"
|
||||||
if [[ ! -x "$bin" ]]; then
|
return
|
||||||
log "NUWIKI_DEV_SKIP_BUILD=1 but $bin is missing — building anyway"
|
fi
|
||||||
else
|
if [[ "${NUWIKI_DEV_SKIP_BUILD:-0}" == "1" ]]; then
|
||||||
log "NUWIKI_DEV_SKIP_BUILD=1 — using existing $bin"
|
log "NUWIKI_DEV_SKIP_BUILD=1 but $bin is missing"
|
||||||
return
|
return 1
|
||||||
fi
|
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
|
fi
|
||||||
log "building nuwiki-ls (release)…"
|
|
||||||
cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml"
|
|
||||||
mkdir -p "$REPO_ROOT/bin"
|
mkdir -p "$REPO_ROOT/bin"
|
||||||
ln -sf "$built" "$bin"
|
ln -sf "$rs_repo/target/release/nuwiki-ls" "$bin"
|
||||||
log "binary ready: $bin (built $(date -r "$built" '+%Y-%m-%d %H:%M:%S'))"
|
log "binary ready: $bin"
|
||||||
}
|
}
|
||||||
|
|
||||||
# write_sample_wiki DIR — lay down a fixture that exercises every vimwiki
|
# write_sample_wiki DIR — lay down a fixture that exercises every vimwiki
|
||||||
|
|||||||
@@ -2,25 +2,17 @@
|
|||||||
#
|
#
|
||||||
# start-nvim.sh — launch Neovim with a minimal config that loads nuwiki.
|
# 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 install() downloads a pre-built release of nuwiki-ls for
|
||||||
# the plugin's default install path resolves, then spawns Neovim with
|
# your platform from the nuwiki-rs repository. On first launch a missing
|
||||||
# `--clean` and a generated `init.lua` that:
|
# binary is auto-downloaded via the bundled Vim script; set
|
||||||
# * adds this repo to `runtimepath`
|
# NUWIKI_DEV_SKIP_BUILD=1 to skip the download entirely.
|
||||||
# * configures wiki_root to a scratch directory under
|
|
||||||
# $XDG_CACHE_HOME/nuwiki-dev/wiki
|
|
||||||
# * calls `require('nuwiki').setup({...})`
|
|
||||||
#
|
#
|
||||||
# Usage: ./development/start-nvim.sh [extra args...]
|
# Usage: ./development/start-nvim.sh [extra args...]
|
||||||
# ./development/start-nvim.sh # open the scratch wiki's index
|
# ./development/start-nvim.sh # open the scratch wiki's index
|
||||||
# ./development/start-nvim.sh path/to/note.wiki # open a specific file
|
# ./development/start-nvim.sh path/to/note.wiki # open a specific file
|
||||||
# NUWIKI_DEV_WIKI=/tmp/foo ./development/start-nvim.sh
|
# 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_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
|
# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-nvim.sh # skip the download step
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# Each launch starts clean: the scratch wiki is reseeded from a pristine
|
# 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
|
# copy and the editor state (cache/data/config) is wiped, so stale edits
|
||||||
|
|||||||
@@ -22,13 +22,12 @@
|
|||||||
# ./development/start-vim-coc.sh path/to/note.wiki # open a specific file
|
# ./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_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_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_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)
|
# 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 LSP binary is downloaded on every launch if missing, falling back to a
|
||||||
# the running LSP. `cargo build --release` is incremental, so this is a fast
|
# cached version. Set NUWIKI_DEV_SKIP_BUILD=1 to skip the download.
|
||||||
# no-op when nothing changed. Set NUWIKI_DEV_SKIP_BUILD=1 to skip the build.
|
|
||||||
#
|
#
|
||||||
# Each launch starts clean: the scratch wiki is reseeded from a pristine
|
# 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
|
# copy and the editor state (viminfo/swap/undo, coc data) is wiped, so stale
|
||||||
|
|||||||
@@ -7,18 +7,18 @@
|
|||||||
# the dev cache on first run so the LSP integration is exercisable
|
# the dev cache on first run so the LSP integration is exercisable
|
||||||
# without polluting your real Vim install.
|
# 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...]
|
# Usage: ./development/start-vim.sh [extra args...]
|
||||||
# ./development/start-vim.sh # open the scratch wiki's index
|
# ./development/start-vim.sh # open the scratch wiki's index
|
||||||
# ./development/start-vim.sh path/to/note.wiki # open a specific file
|
# ./development/start-vim.sh path/to/note.wiki # open a specific file
|
||||||
# NUWIKI_DEV_WIKI=/tmp/foo ./development/start-vim.sh
|
# 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_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_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
|
# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim.sh # skip the download step
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# Each launch starts clean: the scratch wiki is reseeded from a pristine
|
# 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
|
# copy and the editor state (viminfo/swap/undo) is wiped, so stale edits
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
# * opting out with use_calendar = false.
|
# * opting out with use_calendar = false.
|
||||||
#
|
#
|
||||||
# A stub calendar-vim on the runtimepath provides :Calendar and seeds the
|
# A stub calendar-vim on the runtimepath provides :Calendar and seeds the
|
||||||
# default hook names. The LSP binary is built (setup() registers the client);
|
# default hook names. The LSP binary is downloaded (setup() registers the
|
||||||
# the calendar paths themselves don't need the server.
|
# client); the calendar paths themselves don't need the server.
|
||||||
#
|
#
|
||||||
# Exit: 0 when both runs are green, 1 otherwise. Skips (0) without nvim.
|
# 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
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# setup() registers the LSP client, so build the binary like the other
|
# Download the LSP binary from nuwiki-rs releases (falls back to cargo build).
|
||||||
# Neovim harnesses do (incremental, fast).
|
|
||||||
BIN="$REPO_ROOT/bin/nuwiki-ls"
|
BIN="$REPO_ROOT/bin/nuwiki-ls"
|
||||||
log 'building nuwiki-ls (release, incremental)…'
|
log 'downloading nuwiki-ls…'
|
||||||
cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" >/dev/null
|
if [[ ! -x "$BIN" ]]; then
|
||||||
mkdir -p "$REPO_ROOT/bin"
|
vim -e -s -c "source $REPO_ROOT/scripts/download_bin.vim" -c "q" >/dev/null 2>&1 || true
|
||||||
ln -sf "$REPO_ROOT/target/release/nuwiki-ls" "$BIN"
|
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) ----------------
|
# --- Scratch wiki with one seeded diary entry (2026-05-30) ----------------
|
||||||
WIKI="$TMP/wiki"
|
WIKI="$TMP/wiki"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
#
|
#
|
||||||
# The Lua harness needs a real LSP attachment (most keymaps dispatch
|
# The Lua harness needs a real LSP attachment (most keymaps dispatch
|
||||||
# `workspace/executeCommand`), so we:
|
# `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,
|
# 2. spin up a scratch wiki under a temp dir,
|
||||||
# 3. invoke `nvim --headless` with a minimal init that loads the
|
# 3. invoke `nvim --headless` with a minimal init that loads the
|
||||||
# plugin + calls `setup()`, then sources the harness Lua.
|
# 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' "$*"; }
|
log() { printf '\033[1;34m[keymap-test]\033[0m %s\n' "$*"; }
|
||||||
|
|
||||||
# Always rebuild the LSP binary so harness runs against current sources.
|
# Download the LSP binary from nuwiki-rs releases (falls back to cargo build).
|
||||||
# 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.
|
|
||||||
BIN="$REPO_ROOT/bin/nuwiki-ls"
|
BIN="$REPO_ROOT/bin/nuwiki-ls"
|
||||||
log 'building nuwiki-ls (release, incremental)…'
|
log 'downloading nuwiki-ls…'
|
||||||
cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" >/dev/null
|
if [[ ! -x "$BIN" ]]; then
|
||||||
mkdir -p "$REPO_ROOT/bin"
|
vim -e -s -c "source $REPO_ROOT/scripts/download_bin.vim" -c "q" >/dev/null 2>&1 || true
|
||||||
ln -sf "$REPO_ROOT/target/release/nuwiki-ls" "$BIN"
|
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"
|
mkdir -p "$TMP/wiki"
|
||||||
echo "= seed =" > "$TMP/wiki/index.wiki"
|
echo "= seed =" > "$TMP/wiki/index.wiki"
|
||||||
|
|||||||
+10
-22
@@ -50,13 +50,11 @@ Highlights:
|
|||||||
==============================================================================
|
==============================================================================
|
||||||
2. REQUIREMENTS *nuwiki-requirements*
|
2. REQUIREMENTS *nuwiki-requirements*
|
||||||
|
|
||||||
* Neovim 0.11+ (preferred path)
|
* Neovim 0.11+ (preferred path)
|
||||||
* Vim 9.1+ with one of:
|
* Vim 9.1+ with one of:
|
||||||
- vim-lsp (prabirshrestha/vim-lsp)
|
- vim-lsp (prabirshrestha/vim-lsp)
|
||||||
- coc.nvim (neoclide/coc.nvim)
|
- coc.nvim (neoclide/coc.nvim)
|
||||||
* `curl` and `tar` (only for the binary download path; not needed if
|
* `curl` and `tar` (for the binary download path)
|
||||||
you build from source)
|
|
||||||
* `cargo` and a Rust toolchain (only if you opt into source builds)
|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
3. INSTALLATION *nuwiki-installation*
|
3. INSTALLATION *nuwiki-installation*
|
||||||
@@ -83,16 +81,8 @@ lazy.nvim: >
|
|||||||
\ 'build': 'vim -e -s -c "source scripts/download_bin.vim" -c "q"',
|
\ 'build': 'vim -e -s -c "source scripts/download_bin.vim" -c "q"',
|
||||||
\ })
|
\ })
|
||||||
|
|
||||||
<To force a build from source (skip the download path), set: >
|
<Note: the LSP binary is downloaded automatically. No cargo or Rust
|
||||||
|
toolchain is required.==============================================================================
|
||||||
let g:nuwiki_build_from_source = 1
|
|
||||||
|
|
||||||
<in Vim before re-running the build hook, or in Neovim: >
|
|
||||||
|
|
||||||
vim.g.nuwiki_build_from_source = 1
|
|
||||||
require('nuwiki').install()
|
|
||||||
|
|
||||||
<==============================================================================
|
|
||||||
4. CONFIGURATION *nuwiki-config*
|
4. CONFIGURATION *nuwiki-config*
|
||||||
|
|
||||||
Neovim users call `setup()` (omit the block entirely to take every
|
Neovim users call `setup()` (omit the block entirely to take every
|
||||||
@@ -380,12 +370,10 @@ Vim-specific globals ~
|
|||||||
middle-click `:badd`s the link target, etc.). Mirrors
|
middle-click `:badd`s the link target, etc.). Mirrors
|
||||||
`mappings.mouse = true` in Neovim.
|
`mappings.mouse = true` in Neovim.
|
||||||
|
|
||||||
*g:nuwiki_build_from_source*
|
*g:nuwiki_no_calendar*
|
||||||
`g:nuwiki_build_from_source` 0
|
`g:nuwiki_no_calendar` 0
|
||||||
When 1, the install step always builds the server with `cargo` and
|
|
||||||
skips the release-asset download.
|
|
||||||
|
|
||||||
*g:nuwiki_binary_path*
|
*g:nuwiki_binary_path*
|
||||||
`g:nuwiki_binary_path`
|
`g:nuwiki_binary_path`
|
||||||
Override the auto-discovered server binary path. If set and the
|
Override the auto-discovered server binary path. If set and the
|
||||||
file is readable, it overrides the `{plugin}/bin/nuwiki-ls` default.
|
file is readable, it overrides the `{plugin}/bin/nuwiki-ls` default.
|
||||||
|
|||||||
@@ -816,8 +816,7 @@ end
|
|||||||
-- the cursor visually mid-cell after columns expand. For insert-mode
|
-- the cursor visually mid-cell after columns expand. For insert-mode
|
||||||
-- editing we replicate the alignment logic locally so we can realign
|
-- editing we replicate the alignment logic locally so we can realign
|
||||||
-- *before* placing the cursor, all in a single `vim.schedule` tick.
|
-- *before* placing the cursor, all in a single `vim.schedule` tick.
|
||||||
-- The algorithm matches `crates/nuwiki-lsp/src/commands.rs::ops::
|
-- The algorithm matches the LSP server's implementation in nuwiki-rs.
|
||||||
-- render_aligned_table`.
|
|
||||||
|
|
||||||
local function is_table_row(line)
|
local function is_table_row(line)
|
||||||
return line ~= nil and line:match('^%s*|.*|%s*$') ~= nil
|
return line ~= nil and line:match('^%s*|.*|%s*$') ~= nil
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
-- lua/nuwiki/folding.lua — `foldexpr` fallback when the LSP isn't
|
-- lua/nuwiki/folding.lua — `foldexpr` fallback when the LSP isn't
|
||||||
-- attached. Same heading-block model as the server-side `foldingRange`
|
-- attached. Same heading-block model as the server-side `foldingRange`
|
||||||
-- provider in `crates/nuwiki-lsp/src/folding.rs`, but driven from a
|
-- provider in the nuwiki-rs LSP server, but driven from a
|
||||||
-- pure regex over `getline()` so it works without a running server.
|
-- pure regex over `getline()` so it works without a running server.
|
||||||
--
|
--
|
||||||
-- Activated by `setlocal foldmethod=expr foldexpr=v:lua.require'nuwiki.folding'.expr()`
|
-- Activated by `setlocal foldmethod=expr foldexpr=v:lua.require'nuwiki.folding'.expr()`
|
||||||
|
|||||||
+21
-13
@@ -2,9 +2,9 @@
|
|||||||
--
|
--
|
||||||
-- Strategy:
|
-- Strategy:
|
||||||
-- 1. Look for `bin/nuwiki-ls[.exe]` next to the plugin.
|
-- 1. Look for `bin/nuwiki-ls[.exe]` next to the plugin.
|
||||||
-- 2. If missing or `g:nuwiki_build_from_source` is set, build with cargo.
|
-- 2. If missing, download a release asset for the current target from
|
||||||
-- 3. Otherwise try to download a release asset for the current target.
|
-- nuwiki-rs.
|
||||||
-- On failure, fall back to cargo.
|
-- 3. On failure, fall back to cloning nuwiki-rs and building with cargo.
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -51,16 +51,30 @@ local function build_from_source(dest)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local root = plugin_root()
|
local root = plugin_root()
|
||||||
vim.notify('nuwiki: building from source (cargo build --release) …', vim.log.levels.INFO)
|
vim.notify('nuwiki: building nuwiki-ls from source (cargo build --release) …', vim.log.levels.INFO)
|
||||||
|
local rs_repo = vim.fn.fnamemodify(root, ':h:h') .. '/nuwiki-rs'
|
||||||
|
if not vim.uv then vim.uv = vim.loop end
|
||||||
|
local rs_exists = vim.uv.fs_stat(rs_repo)
|
||||||
|
if not rs_exists then
|
||||||
|
vim.notify('nuwiki: cloning nuwiki-rs …', vim.log.levels.INFO)
|
||||||
|
local _ = vim.fn.system({
|
||||||
|
'git', 'clone', '--depth', '1',
|
||||||
|
'https://code.gfran.co/gffranco/nuwiki-rs.git', rs_repo,
|
||||||
|
})
|
||||||
|
if vim.v.shell_error ~= 0 then
|
||||||
|
vim.notify('nuwiki: failed to clone nuwiki-rs', vim.log.levels.ERROR)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
local _ = vim.fn.system({
|
local _ = vim.fn.system({
|
||||||
'cargo', 'build', '--release', '-p', 'nuwiki-ls',
|
'cargo', 'build', '--release', '-p', 'nuwiki-ls',
|
||||||
'--manifest-path', root .. '/Cargo.toml',
|
'--manifest-path', rs_repo .. '/Cargo.toml',
|
||||||
})
|
})
|
||||||
if vim.v.shell_error ~= 0 then
|
if vim.v.shell_error ~= 0 then
|
||||||
vim.notify('nuwiki: cargo build failed', vim.log.levels.ERROR)
|
vim.notify('nuwiki: cargo build failed', vim.log.levels.ERROR)
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local built = root .. '/target/release/nuwiki-ls' .. exe_suffix()
|
local built = rs_repo .. '/target/release/nuwiki-ls' .. exe_suffix()
|
||||||
vim.fn.system({ 'cp', built, dest })
|
vim.fn.system({ 'cp', built, dest })
|
||||||
vim.fn.system({ 'chmod', '+x', dest })
|
vim.fn.system({ 'chmod', '+x', dest })
|
||||||
return vim.fn.executable(dest) == 1
|
return vim.fn.executable(dest) == 1
|
||||||
@@ -74,11 +88,8 @@ local function download_release(dest)
|
|||||||
if vim.fn.executable('curl') ~= 1 or vim.fn.executable('tar') ~= 1 then
|
if vim.fn.executable('curl') ~= 1 or vim.fn.executable('tar') ~= 1 then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
-- Gitea serves the most recent release under the `latest` tag segment
|
|
||||||
-- (`/releases/download/latest/<asset>`). Note this is NOT GitHub's
|
|
||||||
-- `/releases/latest/download/<asset>` shape, which Gitea 404s.
|
|
||||||
local url = string.format(
|
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
|
target
|
||||||
)
|
)
|
||||||
local archive = vim.fn.tempname() .. '.tar.gz'
|
local archive = vim.fn.tempname() .. '.tar.gz'
|
||||||
@@ -101,9 +112,6 @@ function M.install()
|
|||||||
local dest = M.expected_path()
|
local dest = M.expected_path()
|
||||||
vim.fn.mkdir(vim.fn.fnamemodify(dest, ':h'), 'p')
|
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
|
if download_release(dest) then
|
||||||
vim.notify('nuwiki: installed ' .. dest, vim.log.levels.INFO)
|
vim.notify('nuwiki: installed ' .. dest, vim.log.levels.INFO)
|
||||||
return true
|
return true
|
||||||
|
|||||||
+14
-12
@@ -1,5 +1,5 @@
|
|||||||
" scripts/download_bin.vim — install `nuwiki-ls` from a release tarball,
|
" 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:
|
" Invoked by Dein / vim-plug build hooks:
|
||||||
" vim -e -s -c "source scripts/download_bin.vim" -c "q"
|
" vim -e -s -c "source scripts/download_bin.vim" -c "q"
|
||||||
@@ -16,6 +16,7 @@ let s:plugin_dir = fnamemodify(resolve(expand('<sfile>:p')), ':h:h')
|
|||||||
let s:bin_dir = s:plugin_dir . '/bin'
|
let s:bin_dir = s:plugin_dir . '/bin'
|
||||||
let s:suffix = has('win32') ? '.exe' : ''
|
let s:suffix = has('win32') ? '.exe' : ''
|
||||||
let s:bin = s:bin_dir . '/nuwiki-ls' . s:suffix
|
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')
|
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
|
echohl ErrorMsg | echom 'nuwiki: cargo not found, cannot build from source' | echohl None
|
||||||
return 0
|
return 0
|
||||||
endif
|
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 …'
|
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
|
if v:shell_error != 0
|
||||||
echohl ErrorMsg | echom 'nuwiki: cargo build failed' | echohl None
|
echohl ErrorMsg | echom 'nuwiki: cargo build failed' | echohl None
|
||||||
return 0
|
return 0
|
||||||
endif
|
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('cp ' . shellescape(l:built) . ' ' . shellescape(s:bin))
|
||||||
call system('chmod +x ' . shellescape(s:bin))
|
call system('chmod +x ' . shellescape(s:bin))
|
||||||
return executable(s:bin)
|
return executable(s:bin)
|
||||||
@@ -64,10 +73,7 @@ function! s:download_release() abort
|
|||||||
if !executable('curl') || !executable('tar')
|
if !executable('curl') || !executable('tar')
|
||||||
return 0
|
return 0
|
||||||
endif
|
endif
|
||||||
" Gitea serves the most recent release under the `latest` tag segment
|
let l:url = 'https://code.gfran.co/gffranco/nuwiki-rs/releases/download/latest/nuwiki-ls-'
|
||||||
" (/releases/download/latest/<asset>). NOT GitHub's
|
|
||||||
" /releases/latest/download/<asset> shape, which Gitea 404s.
|
|
||||||
let l:url = 'https://code.gfran.co/gffranco/nuwiki/releases/download/latest/nuwiki-ls-'
|
|
||||||
\ . l:target . '.tar.gz'
|
\ . l:target . '.tar.gz'
|
||||||
let l:archive = tempname() . '.tar.gz'
|
let l:archive = tempname() . '.tar.gz'
|
||||||
call system('curl -fsSL -o ' . shellescape(l:archive) . ' ' . shellescape(l:url))
|
call system('curl -fsSL -o ' . shellescape(l:archive) . ' ' . shellescape(l:url))
|
||||||
@@ -85,11 +91,7 @@ function! s:download_release() abort
|
|||||||
return executable(s:bin)
|
return executable(s:bin)
|
||||||
endfunction
|
endfunction
|
||||||
|
|
||||||
if get(g:, 'nuwiki_build_from_source', 0)
|
if s:download_release()
|
||||||
if !s:build_from_source()
|
|
||||||
echohl ErrorMsg | echom 'nuwiki: source build failed' | echohl None
|
|
||||||
endif
|
|
||||||
elseif s:download_release()
|
|
||||||
echom 'nuwiki: installed ' . s:bin
|
echom 'nuwiki: installed ' . s:bin
|
||||||
else
|
else
|
||||||
echohl WarningMsg | echom 'nuwiki: download failed, falling back to source build' | echohl None
|
echohl WarningMsg | echom 'nuwiki: download failed, falling back to source build' | echohl None
|
||||||
|
|||||||
@@ -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 <semver> 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"
|
|
||||||
Reference in New Issue
Block a user