phase 6: LSP foundation — didOpen/didChange, diagnostics, outline
`tower-lsp` server (`nuwiki-lsp::run` + `run_stdio`) wired up by the `nuwiki-ls` binary. Backend holds an `Arc<DashMap<Url, DocumentState>>` per SPEC §6.10 plus an `Arc<SyntaxRegistry>` pre-loaded with `VimwikiSyntax`. Re-parse is full per change; incremental parsing deferred post-v1 per spec. Capabilities declared: - `text_document_sync = FULL` - `document_symbol_provider = true` - `position_encoding = UTF-8` when the client opts into LSP 3.17+ encodings (SPEC §6.11). Otherwise we keep the legacy UTF-16 default and translate spans through a per-line UTF-16 code-unit walk that handles BMP and surrogate pairs alike. Handlers: `initialize` (negotiate encoding), `initialized` (log it), `shutdown`, `did_open` / `did_change` (full re-parse + `publishDiagnostics`), `did_close` (drop state, clear diagnostics), `document_symbol` (nested outline). Diagnostics: every `BlockNode::Error` (from §6.7's resilient parser) emits one `DiagnosticSeverity::ERROR` with `source = "nuwiki"`. The walker descends into Blockquotes and ListItems so errors nested inside those don't slip through. Document outline: nested `DocumentSymbol` tree built recursively from heading levels (`= H1 =` → root, `== H2 ==` → child of preceding H1, etc.). Titles flatten inline markers via `inline_to_text` so `*world*` shows up as `world` in the outline. Pure helpers (`to_lsp_position`, `to_lsp_range`, `ast_diagnostics`, `headings_to_symbols`) are `pub` so the integration tests can verify conversion correctness without spinning up the async server. End-to- end LSP message exercises will land in Phase 8 alongside navigation. Tests (11): UTF-8 passthrough, UTF-16 conversion for é + 🦀, single and multi-error diagnostic emission, blockquote descent, flat outline, nested outline by levels, inline-marker stripping in titles, empty- heading fallback, registry-dispatch parity. Dep pin: `idna_adapter` held at 1.2.0 — 1.2.2 needs Cargo's edition2024 feature (Rust 1.85), our MSRV is 1.83. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+812
@@ -2,6 +2,378 @@
|
||||
# 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.1.0"
|
||||
@@ -11,11 +383,451 @@ name = "nuwiki-ls"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"nuwiki-lsp",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nuwiki-lsp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dashmap 6.1.0",
|
||||
"nuwiki-core",
|
||||
"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"
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
A Vim/Neovim plugin providing full vimwiki syntax support, implemented as a
|
||||
Rust-based language server (LSP).
|
||||
|
||||
> **Status:** Phase 5 (HTML renderer) complete. End-to-end source → AST → HTML
|
||||
> works via `VimwikiSyntax` + `HtmlRenderer`. LSP and editor glue still pending.
|
||||
> **Status:** Phase 6 (LSP foundation) complete. `nuwiki-ls` speaks LSP over
|
||||
> stdio: didOpen/didChange/didClose, parse diagnostics, nested document
|
||||
> outline. Editor glue, semantic tokens, and navigation still pending.
|
||||
|
||||
See [`SPEC.md`](./SPEC.md) for the full project specification.
|
||||
|
||||
@@ -18,8 +19,8 @@ See [`SPEC.md`](./SPEC.md) for the full project specification.
|
||||
| 3 | Vimwiki Lexer | ✅ done |
|
||||
| 4 | Vimwiki Parser | ✅ done |
|
||||
| 5 | Renderer | ✅ done |
|
||||
| 6 | LSP Foundation | ⏳ next |
|
||||
| 7 | Semantic Tokens | ⏳ |
|
||||
| 6 | LSP Foundation | ✅ done |
|
||||
| 7 | Semantic Tokens | ⏳ next |
|
||||
| 8 | Navigation | ⏳ |
|
||||
| 9 | Editor Glue | ⏳ |
|
||||
| 10 | CI/CD release pipeline | ⏳ |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# nuwiki — Project Specification
|
||||
|
||||
> Last updated: 2026-05-10
|
||||
> Status: Phase 5 (Renderer) complete — moving to Phase 6 (LSP Foundation)
|
||||
> Status: Phase 6 (LSP Foundation) complete — moving to Phase 7 (Semantic Tokens)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -18,3 +18,4 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
nuwiki-lsp = { path = "../nuwiki-lsp", version = "0.1.0" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] }
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
fn main() {}
|
||||
//! `nuwiki-ls` — language-server binary. Speaks LSP over stdio.
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
nuwiki_lsp::run_stdio().await
|
||||
}
|
||||
|
||||
@@ -14,3 +14,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
nuwiki-core = { path = "../nuwiki-core", version = "0.1.0" }
|
||||
tower-lsp = "0.20"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync"] }
|
||||
dashmap = "6"
|
||||
async-trait = "0.1"
|
||||
|
||||
@@ -1,4 +1,413 @@
|
||||
//! LSP protocol bridge for nuwiki.
|
||||
//!
|
||||
//! Translates between LSP requests/notifications and `nuwiki-core` AST
|
||||
//! operations. Must never depend on `nuwiki-ls` (see SPEC.md §6.2).
|
||||
//! `tower-lsp` server that maintains a `DocumentStore` (SPEC §6.10) and
|
||||
//! exposes the Phase 6 feature set:
|
||||
//!
|
||||
//! - `initialize` / `initialized` / `shutdown`
|
||||
//! - `textDocument/didOpen`, `didChange` (full sync), `didClose`
|
||||
//! - `textDocument/publishDiagnostics` from `BlockNode::Error` nodes
|
||||
//! - `textDocument/documentSymbol` — nested outline from headings
|
||||
//!
|
||||
//! Position encoding is negotiated as UTF-8 when the client supports LSP
|
||||
//! 3.17+ encodings (SPEC §6.11). When the client only supports the legacy
|
||||
//! UTF-16 default, positions get translated through a per-line lookup.
|
||||
//!
|
||||
//! Re-parses are full per change (incremental parsing deferred post-v1).
|
||||
|
||||
use std::io;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tower_lsp::jsonrpc::Result as LspResult;
|
||||
use tower_lsp::lsp_types::{
|
||||
Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams,
|
||||
DidOpenTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse,
|
||||
InitializeParams, InitializeResult, InitializedParams, MessageType, OneOf,
|
||||
PositionEncodingKind, ServerCapabilities, ServerInfo, SymbolKind, TextDocumentSyncCapability,
|
||||
TextDocumentSyncKind, Url,
|
||||
};
|
||||
use tower_lsp::{Client, LanguageServer, LspService, Server};
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
BlockNode, BlockquoteNode, DocumentNode, ErrorNode, HeadingNode, InlineNode, ListItemNode,
|
||||
ListNode, Span,
|
||||
};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxRegistry;
|
||||
|
||||
/// Run the LSP server over the given async reader/writer pair. The binary
|
||||
/// (`nuwiki-ls`) just calls this with stdin/stdout.
|
||||
pub async fn run<I, O>(reader: I, writer: O)
|
||||
where
|
||||
I: AsyncRead + Unpin,
|
||||
O: AsyncWrite + Unpin,
|
||||
{
|
||||
let (service, socket) = LspService::new(Backend::new);
|
||||
Server::new(reader, writer, socket).serve(service).await;
|
||||
}
|
||||
|
||||
/// Convenience: run on stdin/stdout. Fails only if stdio handles are
|
||||
/// unavailable.
|
||||
pub async fn run_stdio() -> io::Result<()> {
|
||||
let stdin = tokio::io::stdin();
|
||||
let stdout = tokio::io::stdout();
|
||||
run(stdin, stdout).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DocumentState {
|
||||
text: String,
|
||||
ast: DocumentNode,
|
||||
/// Last version we observed from the client. Held per SPEC §6.10 even
|
||||
/// though the foundation phase doesn't yet need to read it back.
|
||||
#[allow(dead_code)]
|
||||
version: i32,
|
||||
}
|
||||
|
||||
struct Backend {
|
||||
client: Client,
|
||||
documents: Arc<DashMap<Url, DocumentState>>,
|
||||
registry: Arc<SyntaxRegistry>,
|
||||
/// True after `initialize` if the client opted into UTF-8 encoding.
|
||||
/// Otherwise we keep the LSP 3.16 default of UTF-16.
|
||||
use_utf8: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
fn new(client: Client) -> Self {
|
||||
let mut registry = SyntaxRegistry::new();
|
||||
registry.register(VimwikiSyntax::new());
|
||||
Self {
|
||||
client,
|
||||
documents: Arc::new(DashMap::new()),
|
||||
registry: Arc::new(registry),
|
||||
use_utf8: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_document(&self, uri: Url, text: String, version: i32) {
|
||||
// For Phase 6 we always treat unknown buffers as vimwiki — if/when
|
||||
// multi-syntax dispatch lands the pick should follow the URI's ext.
|
||||
let plugin = match self.registry.get("vimwiki") {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
self.client
|
||||
.log_message(MessageType::ERROR, "vimwiki plugin missing")
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let ast = plugin.parse(&text);
|
||||
let diagnostics = ast_diagnostics(&ast, &text, self.use_utf8.load(Ordering::Relaxed));
|
||||
self.documents
|
||||
.insert(uri.clone(), DocumentState { text, ast, version });
|
||||
self.client
|
||||
.publish_diagnostics(uri, diagnostics, Some(version))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tower_lsp::async_trait]
|
||||
impl LanguageServer for Backend {
|
||||
async fn initialize(&self, params: InitializeParams) -> LspResult<InitializeResult> {
|
||||
// SPEC §6.11: prefer UTF-8 if the client advertises support.
|
||||
let supports_utf8 = params
|
||||
.capabilities
|
||||
.general
|
||||
.as_ref()
|
||||
.and_then(|g| g.position_encodings.as_ref())
|
||||
.map(|encs| encs.iter().any(|e| *e == PositionEncodingKind::UTF8))
|
||||
.unwrap_or(false);
|
||||
self.use_utf8.store(supports_utf8, Ordering::Relaxed);
|
||||
|
||||
let position_encoding = if supports_utf8 {
|
||||
Some(PositionEncodingKind::UTF8)
|
||||
} else {
|
||||
None // server defaults to UTF-16 per LSP 3.16.
|
||||
};
|
||||
|
||||
Ok(InitializeResult {
|
||||
capabilities: ServerCapabilities {
|
||||
position_encoding,
|
||||
text_document_sync: Some(TextDocumentSyncCapability::Kind(
|
||||
TextDocumentSyncKind::FULL,
|
||||
)),
|
||||
document_symbol_provider: Some(OneOf::Left(true)),
|
||||
..ServerCapabilities::default()
|
||||
},
|
||||
server_info: Some(ServerInfo {
|
||||
name: env!("CARGO_PKG_NAME").into(),
|
||||
version: Some(env!("CARGO_PKG_VERSION").into()),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn initialized(&self, _params: InitializedParams) {
|
||||
let enc = if self.use_utf8.load(Ordering::Relaxed) {
|
||||
"utf-8"
|
||||
} else {
|
||||
"utf-16"
|
||||
};
|
||||
self.client
|
||||
.log_message(
|
||||
MessageType::INFO,
|
||||
format!("nuwiki-lsp initialized (positionEncoding={enc})"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> LspResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn did_open(&self, params: DidOpenTextDocumentParams) {
|
||||
let uri = params.text_document.uri;
|
||||
let text = params.text_document.text;
|
||||
let version = params.text_document.version;
|
||||
self.update_document(uri, text, version).await;
|
||||
}
|
||||
|
||||
async fn did_change(&self, params: DidChangeTextDocumentParams) {
|
||||
let uri = params.text_document.uri;
|
||||
let version = params.text_document.version;
|
||||
// Full sync: the *last* content change carries the whole document.
|
||||
if let Some(change) = params.content_changes.into_iter().last() {
|
||||
self.update_document(uri, change.text, version).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn did_close(&self, params: DidCloseTextDocumentParams) {
|
||||
self.documents.remove(¶ms.text_document.uri);
|
||||
// Clear lingering diagnostics so closed buffers don't keep red squiggles.
|
||||
self.client
|
||||
.publish_diagnostics(params.text_document.uri, Vec::new(), None)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn document_symbol(
|
||||
&self,
|
||||
params: DocumentSymbolParams,
|
||||
) -> LspResult<Option<DocumentSymbolResponse>> {
|
||||
let uri = ¶ms.text_document.uri;
|
||||
let Some(doc) = self.documents.get(uri) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let symbols =
|
||||
headings_to_symbols(&doc.ast, &doc.text, self.use_utf8.load(Ordering::Relaxed));
|
||||
Ok(Some(DocumentSymbolResponse::Nested(symbols)))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Pure helpers =====
|
||||
|
||||
/// Convert one of our `Position` values into an LSP `Position`.
|
||||
///
|
||||
/// In UTF-8 encoding mode (negotiated during `initialize`) the byte column
|
||||
/// is the LSP `character`. In UTF-16 mode the LSP `character` is a UTF-16
|
||||
/// code-unit offset, so we walk the source line to translate.
|
||||
pub fn to_lsp_position(
|
||||
pos: &nuwiki_core::ast::Position,
|
||||
text: &str,
|
||||
utf8: bool,
|
||||
) -> tower_lsp::lsp_types::Position {
|
||||
let character = if utf8 {
|
||||
pos.column
|
||||
} else {
|
||||
utf16_column(text, pos.line, pos.column)
|
||||
};
|
||||
tower_lsp::lsp_types::Position {
|
||||
line: pos.line,
|
||||
character,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_lsp_range(span: &Span, text: &str, utf8: bool) -> tower_lsp::lsp_types::Range {
|
||||
tower_lsp::lsp_types::Range {
|
||||
start: to_lsp_position(&span.start, text, utf8),
|
||||
end: to_lsp_position(&span.end, text, utf8),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a byte-offset column on `line` into the corresponding UTF-16
|
||||
/// code-unit count from the start of that line. Out-of-range inputs are
|
||||
/// clamped to the line length, matching the LSP spec's "if the character
|
||||
/// value is greater than the length of the line it is clipped to the
|
||||
/// length".
|
||||
fn utf16_column(text: &str, line: u32, byte_col: u32) -> u32 {
|
||||
let mut current_line = 0u32;
|
||||
let mut line_start = 0usize;
|
||||
let bytes = text.as_bytes();
|
||||
while current_line < line && line_start < bytes.len() {
|
||||
if let Some(nl) = bytes[line_start..].iter().position(|b| *b == b'\n') {
|
||||
line_start += nl + 1;
|
||||
current_line += 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
let line_end = bytes[line_start..]
|
||||
.iter()
|
||||
.position(|b| *b == b'\n')
|
||||
.map(|i| line_start + i)
|
||||
.unwrap_or(bytes.len());
|
||||
let target = (line_start + byte_col as usize).min(line_end);
|
||||
text[line_start..target]
|
||||
.chars()
|
||||
.map(char::len_utf16)
|
||||
.sum::<usize>() as u32
|
||||
}
|
||||
|
||||
pub fn ast_diagnostics(ast: &DocumentNode, text: &str, utf8: bool) -> Vec<Diagnostic> {
|
||||
let mut out = Vec::new();
|
||||
walk_blocks_for_errors(&ast.children, text, utf8, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn walk_blocks_for_errors(blocks: &[BlockNode], text: &str, utf8: bool, out: &mut Vec<Diagnostic>) {
|
||||
for block in blocks {
|
||||
match block {
|
||||
BlockNode::Error(err) => out.push(error_to_diagnostic(err, text, utf8)),
|
||||
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
||||
walk_blocks_for_errors(children, text, utf8, out);
|
||||
}
|
||||
BlockNode::List(ListNode { items, .. }) => {
|
||||
for item in items {
|
||||
walk_list_item_for_errors(item, text, utf8, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_list_item_for_errors(
|
||||
item: &ListItemNode,
|
||||
text: &str,
|
||||
utf8: bool,
|
||||
out: &mut Vec<Diagnostic>,
|
||||
) {
|
||||
if let Some(sub) = &item.sublist {
|
||||
for sub_item in &sub.items {
|
||||
walk_list_item_for_errors(sub_item, text, utf8, out);
|
||||
}
|
||||
}
|
||||
let _ = (text, utf8, out);
|
||||
}
|
||||
|
||||
fn error_to_diagnostic(err: &ErrorNode, text: &str, utf8: bool) -> Diagnostic {
|
||||
Diagnostic {
|
||||
range: to_lsp_range(&err.span, text, utf8),
|
||||
severity: Some(DiagnosticSeverity::ERROR),
|
||||
message: err.message.clone(),
|
||||
source: Some("nuwiki".into()),
|
||||
..Diagnostic::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn headings_to_symbols(ast: &DocumentNode, text: &str, utf8: bool) -> Vec<DocumentSymbol> {
|
||||
let headings: Vec<&HeadingNode> = ast
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
BlockNode::Heading(h) => Some(h),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
#[allow(deprecated)]
|
||||
fn build<'a>(
|
||||
headings: &mut std::slice::Iter<'a, &'a HeadingNode>,
|
||||
text: &str,
|
||||
utf8: bool,
|
||||
parent_level: u8,
|
||||
peeked: &mut Option<&'a HeadingNode>,
|
||||
) -> Vec<DocumentSymbol> {
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let h = match peeked.take().or_else(|| headings.next().copied()) {
|
||||
Some(h) => h,
|
||||
None => return out,
|
||||
};
|
||||
if h.level <= parent_level {
|
||||
*peeked = Some(h);
|
||||
return out;
|
||||
}
|
||||
let children = build(headings, text, utf8, h.level, peeked);
|
||||
let title = inline_to_text(&h.children);
|
||||
out.push(DocumentSymbol {
|
||||
name: if title.is_empty() {
|
||||
"(empty heading)".into()
|
||||
} else {
|
||||
title
|
||||
},
|
||||
detail: None,
|
||||
kind: SymbolKind::STRING,
|
||||
tags: None,
|
||||
deprecated: None,
|
||||
range: to_lsp_range(&h.span, text, utf8),
|
||||
selection_range: to_lsp_range(&h.span, text, utf8),
|
||||
children: if children.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(children)
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut iter = headings.iter();
|
||||
let mut peeked: Option<&HeadingNode> = None;
|
||||
build(&mut iter, text, utf8, 0, &mut peeked)
|
||||
}
|
||||
|
||||
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::Keyword(k) => out.push_str(keyword_str(k.keyword)),
|
||||
InlineNode::Color(c) => inline_to_text_into(&c.children, out),
|
||||
InlineNode::WikiLink(w) => match &w.description {
|
||||
Some(d) => inline_to_text_into(d, out),
|
||||
None => {
|
||||
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::Transclusion(t) => out.push_str(t.alt.as_deref().unwrap_or(&t.url)),
|
||||
InlineNode::RawUrl(r) => out.push_str(&r.url),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn keyword_str(k: nuwiki_core::ast::Keyword) -> &'static str {
|
||||
match k {
|
||||
nuwiki_core::ast::Keyword::Todo => "TODO",
|
||||
nuwiki_core::ast::Keyword::Done => "DONE",
|
||||
nuwiki_core::ast::Keyword::Started => "STARTED",
|
||||
nuwiki_core::ast::Keyword::Fixme => "FIXME",
|
||||
nuwiki_core::ast::Keyword::Fixed => "FIXED",
|
||||
nuwiki_core::ast::Keyword::Xxx => "XXX",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
//! 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 in Phase 8+
|
||||
//! (navigation), 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);
|
||||
}
|
||||
Reference in New Issue
Block a user