//! LSP protocol bridge for nuwiki. //! //! `tower-lsp` server that maintains a `DocumentStore` plus a //! workspace-wide index and exposes every LSP method: //! //! - `initialize` / `initialized` / `shutdown` //! - `textDocument/didOpen`, `didChange` (full sync), `didClose` //! - `textDocument/publishDiagnostics` from `BlockNode::Error` nodes //! - `textDocument/documentSymbol` — nested outline from headings //! - `textDocument/semanticTokens/full` + `/range` //! - `textDocument/definition`, `references`, `hover` //! - `textDocument/completion` (trigger: `[`) //! - `workspace/symbol` — search across indexed headings //! //! Position encoding is negotiated as UTF-8 when the client supports LSP //! 3.17+ encodings. When the client only supports the legacy //! UTF-16 default, positions get translated through a per-line lookup. //! //! Re-parses are full per change (no incremental parsing). //! The workspace scan runs in a background tokio task spawned from //! `initialize` with `window/workDoneProgress` begin/end events. pub mod commands; pub mod config; pub mod diagnostics; pub mod diary; pub mod edits; pub mod export; pub mod folding; pub mod index; pub mod nav; pub mod rename; pub mod semantic_tokens; pub mod wiki; use std::io; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, RwLock}; use dashmap::DashMap; use tokio::io::{AsyncRead, AsyncWrite}; use tower_lsp::jsonrpc::Result as LspResult; use tower_lsp::lsp_types::{ CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse, DeleteFilesParams, Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, ExecuteCommandOptions, ExecuteCommandParams, FileOperationFilter, FileOperationPattern, FileOperationPatternKind, FileOperationRegistrationOptions, FoldingRange, FoldingRangeParams, FoldingRangeProviderCapability, GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams, Location, MarkupContent, MarkupKind, MessageType, OneOf, PositionEncodingKind, ProgressParams, ProgressParamsValue, ProgressToken, Range, ReferenceParams, RenameFilesParams, RenameParams, SaveOptions, SemanticTokens, SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams, SemanticTokensRangeParams, SemanticTokensRangeResult, SemanticTokensResult, SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo, SymbolInformation as LspSymbolInformation, SymbolKind, TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions, TextDocumentSyncSaveOptions, Url, WorkDoneProgress, WorkDoneProgressBegin, WorkDoneProgressEnd, WorkDoneProgressOptions, WorkspaceEdit, WorkspaceFileOperationsServerCapabilities, WorkspaceServerCapabilities, WorkspaceSymbolParams, }; 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; use crate::config::{Config, DiagnosticConfig}; use crate::index::{IndexedPage, WorkspaceIndex}; use crate::wiki::{build_wikis, resolve_uri_to_wiki, Wiki, WikiId}; /// Snapshot of a document — either live (held in the documents map) or /// read from disk on demand. Used by cross-document operations /// (`workspace/rename`, link health checks for closed pages, …) so /// callers don't trust potentially stale index spans. #[derive(Debug)] pub struct DocSnapshot { pub text: String, pub ast: DocumentNode, pub in_memory: bool, } /// 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(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 even /// though nothing reads it back yet. #[allow(dead_code)] version: i32, } struct Backend { client: Client, documents: Arc>, registry: Arc, /// 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, /// Per-wiki state. Vector holds one entry until multi-wiki /// support lets users add more. wikis: Arc>>, /// Server config received via `initializationOptions` and refreshed /// via `workspace/didChangeConfiguration` (P18). config: Arc>, } 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)), wikis: Arc::new(RwLock::new(Vec::new())), config: Arc::new(RwLock::new(Config::default())), } } fn wiki(&self, id: WikiId) -> Option { let wikis = self.wikis.read().ok()?; wikis.iter().find(|w| w.id == id).cloned() } /// Used by workspace-level commands (`checkLinks`, `findOrphans`) /// when no URI is supplied — they fall back to the first registered wiki. fn default_wiki(&self) -> Option { let wikis = self.wikis.read().ok()?; wikis.first().cloned() } /// If the cursor sits on a wikilink, return the linked page's URI so /// `rename` operates on the *target* (matches vimwiki's behaviour). /// Otherwise return `None` and the caller falls back to the buffer's /// own URI. fn rename_target_uri(&self, uri: &Url, pos: tower_lsp::lsp_types::Position) -> Option { let doc = self.documents.get(uri)?; let utf8 = self.use_utf8.load(Ordering::Relaxed); let (line, col) = nav::lsp_to_byte_pos(pos, &doc.text, utf8); let node = nav::find_inline_at(&doc.ast, line, col)?; let wikilink = match node { InlineNode::WikiLink(w) => w, _ => return None, }; let wiki = self.wiki_for_uri(uri)?; let source_page = index::page_name_from_uri(uri, Some(&wiki.config.root)); let idx = wiki.index.read().ok()?; idx.resolve(&wikilink.target, &source_page).cloned() } /// Resolve `uri` to the wiki whose root is the longest prefix of it. /// Falls back to the default wiki when no root matches — that's the /// usual case for ad-hoc `.wiki` files outside any registered wiki. fn wiki_for_uri(&self, uri: &Url) -> Option { let id_or_default = { let wikis = self.wikis.read().ok()?; resolve_uri_to_wiki(&wikis, uri).or_else(|| wikis.first().map(|w| w.id)) }; id_or_default.and_then(|id| self.wiki(id)) } /// Parse `text` as vimwiki, honouring the checkbox palette /// (`listsyms`) of the wiki owning `uri`. Falls back to the default /// palette when no wiki matches. fn parse_for_uri(&self, uri: &Url, text: &str) -> nuwiki_core::ast::DocumentNode { let syms = self .wiki_for_uri(uri) .map(|w| nuwiki_core::listsyms::ListSyms::new(&w.config.listsyms)) .unwrap_or_default(); nuwiki_core::syntax::vimwiki::VimwikiSyntax::new().parse_with_listsyms(text, &syms) } /// Snapshot of every registered wiki — held by value so callers can /// drop the lock before awaiting or building responses. pub(crate) fn wikis_snapshot(&self) -> Vec { self.wikis.read().map(|g| g.clone()).unwrap_or_default() } /// Look up a wiki by its position in the `wikis` Vec (used for the /// `wiki:` interwiki prefix). fn wiki_by_index(&self, idx: usize) -> Option { let wikis = self.wikis.read().ok()?; wikis.get(idx).cloned() } /// Look up a wiki by its configured `name` (used for the /// `wn.:` interwiki prefix). Match is exact, case-sensitive. fn wiki_by_name(&self, name: &str) -> Option { let wikis = self.wikis.read().ok()?; wikis.iter().find(|w| w.config.name == name).cloned() } /// Resolve a `LinkTarget` to the destination page's URI. /// /// - `LinkKind::Wiki` — looks the page up in the source wiki's /// index first; if it isn't indexed yet (the user is following /// a link to a page that doesn't exist on disk), synthesises the /// URI from the wiki root + path + file extension so the editor /// can open a fresh buffer for it. Matches vimwiki's /// "follow-link creates the page on save" behaviour. /// - `LinkKind::AnchorOnly` — same source-wiki lookup; returns /// `None` when the source page isn't indexed. /// - `LinkKind::Interwiki` — routes to the wiki referenced by /// `wiki_index` or `wiki_name`. Falls back to a synthesised URI /// in the target wiki's root when the page isn't indexed. /// - Other kinds (`File`/`Local`/`Diary`/`Raw`) — no resolution /// here; the LSP handler builds those URIs separately. fn resolve_target_uri( &self, target: &nuwiki_core::ast::LinkTarget, source_uri: &Url, ) -> Option { use nuwiki_core::ast::LinkKind; match target.kind { LinkKind::Interwiki => { let target_wiki = if let Some(idx) = target.wiki_index { self.wiki_by_index(idx)? } else if let Some(name) = target.wiki_name.as_deref() { self.wiki_by_name(name)? } else { return None; }; let path = target.path.as_deref()?; if let Ok(idx) = target_wiki.index.read() { let normalized = idx.strip_link_extension(path); if let Some(uri) = idx.pages_by_name.get(normalized).cloned() { return Some(uri); } } synthesise_page_uri(&target_wiki.config, path) } LinkKind::Wiki => { if let Some(source_wiki) = self.wiki_for_uri(source_uri) { let source_page = index::page_name_from_uri( source_uri, Some(source_wiki.config.root.as_path()), ); if let Ok(idx) = source_wiki.index.read() { if let Some(uri) = idx.resolve(target, &source_page).cloned() { return Some(uri); } } let path = target.path.as_deref()?; return synthesise_page_uri(&source_wiki.config, path); } // No wiki registered (no `initializationOptions`, no // workspace folder). Fall back to the source buffer's // parent directory so `` on `[[NewPage]]` still // opens (and on save creates) the future page next to // the current file. let path = target.path.as_deref()?; synthesise_page_uri_next_to(source_uri, path) } _ => { let source_wiki = self.wiki_for_uri(source_uri)?; let source_page = index::page_name_from_uri(source_uri, Some(source_wiki.config.root.as_path())); let idx = source_wiki.index.read().ok()?; idx.resolve(target, &source_page).cloned() } } } /// Compute the LSP `Range` for an anchor (heading or tag) on the /// page at `target_uri`. Resolves the *target* wiki, so interwiki /// anchors land at the correct heading in the cross-wiki page. fn heading_range_for(&self, target_uri: &Url, anchor: &str, utf8: bool) -> Option { let wiki = self.wiki_for_uri(target_uri)?; let idx = wiki.index.read().ok()?; heading_range_in(&idx, target_uri, anchor, utf8) } /// Return live state if `uri` is open in the editor, otherwise read /// the file from disk and re-parse. The closed-doc path is used by /// cross-document operations (`workspace/rename` and link /// health) that need accurate spans + text without trusting cached /// `WorkspaceIndex` data. pub async fn read_or_open(&self, uri: &Url) -> io::Result { if let Some(doc) = self.documents.get(uri) { return Ok(DocSnapshot { text: doc.text.clone(), ast: doc.ast.clone(), in_memory: true, }); } let path = uri .to_file_path() .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "non-file URI"))?; let text = tokio::fs::read_to_string(&path).await?; if self.registry.get("vimwiki").is_none() { return Err(io::Error::other("vimwiki syntax plugin missing")); } let ast = self.parse_for_uri(uri, &text); Ok(DocSnapshot { text, ast, in_memory: false, }) } async fn update_document(&self, uri: Url, text: String, version: i32) { // We always treat unknown buffers as vimwiki — if/when // multi-syntax dispatch lands the pick should follow the URI's ext. if self.registry.get("vimwiki").is_none() { self.client .log_message(MessageType::ERROR, "vimwiki plugin missing") .await; return; } let ast = self.parse_for_uri(&uri, &text); let utf8 = self.use_utf8.load(Ordering::Relaxed); // Compute diagnostics + update the matching wiki's index. All locks // released before the `await` below. let diagnostics = { let wiki = self.wiki_for_uri(&uri); let cfg = self.config.read().expect("config lock poisoned"); let page_name = wiki .as_ref() .map(|w| crate::index::page_name_from_uri(&uri, Some(&w.config.root))); let diags = if let Some(w) = wiki.as_ref() { let idx_guard = w.index.read().expect("index lock poisoned"); collect_diagnostics( &ast, &text, Some(&uri), Some(&idx_guard), page_name.as_deref(), &cfg.diagnostic, utf8, ) } else { collect_diagnostics(&ast, &text, Some(&uri), None, None, &cfg.diagnostic, utf8) }; diags }; if let Some(wiki) = self.wiki_for_uri(&uri) { let mut idx = wiki.index.write().expect("index lock poisoned"); idx.upsert(uri.clone(), &ast); } 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 { // 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. }; // Build server config from initializationOptions (P18) and the // workspace folders, then materialise the `Wiki` aggregate (P11). let cfg = Config::from_init_params(¶ms); *self.wikis.write().expect("wikis lock poisoned") = build_wikis(&cfg.wikis); *self.config.write().expect("config lock poisoned") = cfg; Ok(InitializeResult { capabilities: ServerCapabilities { position_encoding, text_document_sync: Some(TextDocumentSyncCapability::Options( TextDocumentSyncOptions { open_close: Some(true), change: Some(TextDocumentSyncKind::FULL), will_save: None, will_save_wait_until: None, // `auto_export` listens on `did_save`. Asking // for `include_text: false` since the document store // already mirrors the live buffer. save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions { include_text: Some(false), })), }, )), document_symbol_provider: Some(OneOf::Left(true)), workspace_symbol_provider: Some(OneOf::Left(true)), definition_provider: Some(OneOf::Left(true)), references_provider: Some(OneOf::Left(true)), rename_provider: Some(OneOf::Left(true)), execute_command_provider: Some(ExecuteCommandOptions { commands: commands::COMMANDS .iter() .map(|s| (*s).to_string()) .collect(), work_done_progress_options: WorkDoneProgressOptions::default(), }), hover_provider: Some(HoverProviderCapability::Simple(true)), completion_provider: Some(CompletionOptions { trigger_characters: Some(vec!["[".into()]), ..CompletionOptions::default() }), semantic_tokens_provider: Some( SemanticTokensServerCapabilities::SemanticTokensOptions( SemanticTokensOptions { work_done_progress_options: WorkDoneProgressOptions::default(), legend: semantic_tokens::legend(), range: Some(true), full: Some(SemanticTokensFullOptions::Bool(true)), }, ), ), workspace: Some(WorkspaceServerCapabilities { workspace_folders: None, file_operations: Some(wiki_file_operations()), }), folding_range_provider: Some(FoldingRangeProviderCapability::Simple(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; // Kick off the background workspace scan (P8) — one task per wiki. // When each scan finishes, refresh diagnostics for any docs that were // opened while the index was still empty (their link-health checks // would have flagged every link as missing-page). let wikis = self.wikis.read().expect("wikis lock poisoned").clone(); for wiki in wikis { if wiki.config.root.as_os_str().is_empty() { continue; } let scan_client = self.client.clone(); let refresh_client = self.client.clone(); let registry = Arc::clone(&self.registry); let index = Arc::clone(&wiki.index); let root = wiki.config.root.clone(); let documents = Arc::clone(&self.documents); let wikis_arc = Arc::clone(&self.wikis); let config = Arc::clone(&self.config); let use_utf8 = Arc::clone(&self.use_utf8); let wiki_id = wiki.id; tokio::spawn(async move { index_workspace(root, index, scan_client, registry).await; refresh_open_diagnostics_for_wiki( wiki_id, documents, wikis_arc, config, refresh_client, use_utf8, ) .await; }); } } async fn did_change_configuration(&self, params: DidChangeConfigurationParams) { // Apply the new settings to `Config`. Re-indexing on root changes // is out of scope here; for now we just accept the new // values so later commands see the right diagnostic settings etc. // Multi-wiki support revisits this with proper rebuild semantics. { let mut cfg = self.config.write().expect("config lock poisoned"); cfg.apply_change(¶ms.settings); } // A severity change (e.g. diagnostic.link_severity) only affects // already-published diagnostics once we re-run link-health checks for // open docs — otherwise the editor keeps showing the old severity until // the next edit. Refresh every wiki's open docs against the new config. let wiki_ids: Vec = self .wikis .read() .expect("wikis lock poisoned") .iter() .map(|w| w.id) .collect(); for wiki_id in wiki_ids { refresh_open_diagnostics_for_wiki( wiki_id, Arc::clone(&self.documents), Arc::clone(&self.wikis), Arc::clone(&self.config), self.client.clone(), Arc::clone(&self.use_utf8), ) .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_rename_files(&self, params: RenameFilesParams) { // We advertise this capability so the editor pushes // out-of-band renames (file explorer drags, `:VimwikiRenameFile` // when the client opts to surface the new URI). Update the index // so backlinks/headings track the new URI without waiting for // didOpen on the renamed buffer. for fr in ¶ms.files { let Ok(old_uri) = Url::parse(&fr.old_uri) else { continue; }; let Ok(new_uri) = Url::parse(&fr.new_uri) else { continue; }; // Pull the live document forward if it was open under the old // URI. Otherwise read the new path from disk and parse. if let Some((_, state)) = self.documents.remove(&old_uri) { self.documents.insert(new_uri.clone(), state); } if let Some(wiki) = self.wiki_for_uri(&new_uri) { let mut idx = wiki.index.write().expect("index lock poisoned"); idx.remove(&old_uri); } if let Ok(snapshot) = self.read_or_open(&new_uri).await { if let Some(wiki) = self.wiki_for_uri(&new_uri) { let mut idx = wiki.index.write().expect("index lock poisoned"); idx.upsert(new_uri.clone(), &snapshot.ast); } } } } async fn did_delete_files(&self, params: DeleteFilesParams) { // Mirror of did_rename: drop the index entry so backlink lookups // stop returning the now-missing source URI. for f in ¶ms.files { let Ok(uri) = Url::parse(&f.uri) else { continue; }; self.documents.remove(&uri); if let Some(wiki) = self.wiki_for_uri(&uri) { let mut idx = wiki.index.write().expect("index lock poisoned"); idx.remove(&uri); } } } async fn did_save(&self, params: DidSaveTextDocumentParams) { let uri = params.text_document.uri.clone(); let wiki = match self.wiki_for_uri(&uri) { Some(w) => w, None => return, }; // When `auto_toc` is set, rebuild an existing `= Contents =` section // on save and push the change back to the buffer via // `workspace/applyEdit`. Only rebuilds a TOC that's already there — // it never inserts one into a page that didn't have it. if wiki.config.auto_toc { let edit = { self.documents.get(&uri).and_then(|doc| { commands::ops::toc_rebuild_edit( &doc.text, &doc.ast, &uri, self.use_utf8.load(Ordering::Relaxed), ) }) }; if let Some(edit) = edit { let _ = self.client.apply_edit(edit).await; } } // When `auto_export` is set on the resolved wiki, run // the equivalent of `nuwiki.export.currentToHtml` after the file // hits disk. Best-effort — failures are logged but do not bubble // to the client. Skips pages with the `%nohtml` directive. if !wiki.config.html.auto_export { return; } let doc = match self.documents.get(&uri) { Some(d) => d, None => return, }; if doc.ast.metadata.nohtml { return; } let name = index::page_name_from_uri(&uri, Some(&wiki.config.root)); let outcome = commands::export_ops::write_page(&doc.ast, &name, &wiki.config); drop(doc); match outcome { Ok(out) => { let _ = commands::export_ops::ensure_css(&wiki.config); self.client .log_message( MessageType::INFO, format!( "nuwiki: auto-exported {} → {}", name, out.output_path.display() ), ) .await; } Err(e) => { self.client .log_message( MessageType::ERROR, format!("nuwiki: auto-export of {name} failed — {e}"), ) .await; } } } async fn did_close(&self, params: DidCloseTextDocumentParams) { let uri = ¶ms.text_document.uri; self.documents.remove(uri); // Keep the index entry — the file may still exist on disk and other // pages can still link to it. We only drop the live document state. // Clear lingering diagnostics so closed buffers don't keep red squiggles. self.client .publish_diagnostics(params.text_document.uri.clone(), Vec::new(), None) .await; } async fn document_symbol( &self, params: DocumentSymbolParams, ) -> LspResult> { 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))) } async fn semantic_tokens_full( &self, params: SemanticTokensParams, ) -> LspResult> { let uri = ¶ms.text_document.uri; let Some(doc) = self.documents.get(uri) else { return Ok(None); }; let utf8 = self.use_utf8.load(Ordering::Relaxed); let data = semantic_tokens::build_data(&doc.ast, &doc.text, utf8, None); Ok(Some(SemanticTokensResult::Tokens(SemanticTokens { result_id: None, data: pack_data(&data), }))) } async fn semantic_tokens_range( &self, params: SemanticTokensRangeParams, ) -> LspResult> { let uri = ¶ms.text_document.uri; let Some(doc) = self.documents.get(uri) else { return Ok(None); }; let utf8 = self.use_utf8.load(Ordering::Relaxed); let data = semantic_tokens::build_data(&doc.ast, &doc.text, utf8, Some(params.range)); Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens { result_id: None, data: pack_data(&data), }))) } async fn goto_definition( &self, params: GotoDefinitionParams, ) -> LspResult> { let pos = params.text_document_position_params.position; let uri = ¶ms.text_document_position_params.text_document.uri; let Some(doc) = self.documents.get(uri) else { return Ok(None); }; let utf8 = self.use_utf8.load(Ordering::Relaxed); let (line, col) = nav::lsp_to_byte_pos(pos, &doc.text, utf8); let Some(node) = nav::find_inline_at(&doc.ast, line, col) else { return Ok(None); }; let location = match node { InlineNode::WikiLink(w) => self.resolve_target_uri(&w.target, uri).map(|target| { let target_range = w .target .anchor .as_deref() .and_then(|a| self.heading_range_for(&target, a, utf8)) .unwrap_or_else(zero_range); Location { uri: target, range: target_range, } }), InlineNode::ExternalLink(_) | InlineNode::RawUrl(_) => None, _ => None, }; Ok(location.map(GotoDefinitionResponse::Scalar)) } async fn references(&self, params: ReferenceParams) -> LspResult>> { let pos = params.text_document_position.position; let uri = ¶ms.text_document_position.text_document.uri; let Some(doc) = self.documents.get(uri) else { return Ok(None); }; let utf8 = self.use_utf8.load(Ordering::Relaxed); let (line, col) = nav::lsp_to_byte_pos(pos, &doc.text, utf8); // Identify the page (and optional anchor) the cursor is "asking // about". Two cases: // - cursor is on a wikilink → references to the linked page // - cursor isn't on a link → references to the *current* page let wiki = self.wiki_for_uri(uri); let source_page = index::page_name_from_uri(uri, wiki.as_ref().map(|w| w.config.root.as_path())); let target_page = match nav::find_inline_at(&doc.ast, line, col) { Some(InlineNode::WikiLink(w)) => match w.target.kind { nuwiki_core::ast::LinkKind::Wiki => w.target.path.as_ref().map(|p| { let ext = wiki.as_ref().map(|w| w.config.file_extension.as_str()); index::strip_wiki_extension(p, ext).to_string() }), nuwiki_core::ast::LinkKind::AnchorOnly => Some(source_page.clone()), _ => None, }, _ => Some(source_page), }; let Some(page) = target_page else { return Ok(None); }; let Some(wiki) = wiki else { return Ok(Some(Vec::new())); }; let idx = wiki.index.read().expect("index lock poisoned"); let locations: Vec = idx .backlinks_for(&page) .iter() .filter_map(|b| { self.documents.get(&b.source_uri).map(|src| Location { uri: b.source_uri.clone(), range: span_to_lsp_range(&b.source_span, &src.text, utf8), }) }) .collect(); if locations.is_empty() { // Even without open docs we can still produce LSP ranges from // the stored span; the editor opens the file to display it. let locations: Vec = idx .backlinks_for(&page) .iter() .map(|b| Location { uri: b.source_uri.clone(), range: span_to_lsp_range_no_text(&b.source_span), }) .collect(); return Ok(Some(locations)); } Ok(Some(locations)) } async fn hover(&self, params: HoverParams) -> LspResult> { let pos = params.text_document_position_params.position; let uri = ¶ms.text_document_position_params.text_document.uri; let Some(doc) = self.documents.get(uri) else { return Ok(None); }; let utf8 = self.use_utf8.load(Ordering::Relaxed); let (line, col) = nav::lsp_to_byte_pos(pos, &doc.text, utf8); let Some(node) = nav::find_inline_at(&doc.ast, line, col) else { return Ok(None); }; let (markdown, hover_span) = match node { InlineNode::WikiLink(w) => { let body = match self.resolve_target_uri(&w.target, uri) { Some(target_uri) => { // Look up the page in whichever wiki owns the // resolved URI — for interwiki targets this is // the destination wiki, not the source. let wiki = self.wiki_for_uri(&target_uri); match wiki.as_ref() { Some(wiki) => { let idx = wiki.index.read().expect("index lock poisoned"); match idx.page(&target_uri) { Some(page) => preview_for(page, w.target.anchor.as_deref()), None => match &w.target.path { Some(p) => format!("**{p}** — page not in index"), None => "(unresolved link)".into(), }, } } None => "(no wiki configured)".into(), } } None => match &w.target.path { Some(p) => format!("**{p}** — page not in index"), None => "(unresolved link)".into(), }, }; (body, w.span) } InlineNode::ExternalLink(e) => (format!("[{0}]({0})", e.url), e.span), InlineNode::RawUrl(u) => (format!("<{0}>", u.url), u.span), _ => return Ok(None), }; Ok(Some(Hover { contents: HoverContents::Markup(MarkupContent { kind: MarkupKind::Markdown, value: markdown, }), range: Some(span_to_lsp_range(&hover_span, &doc.text, utf8)), })) } async fn completion(&self, params: CompletionParams) -> LspResult> { let uri = ¶ms.text_document_position.text_document.uri; let Some(doc) = self.documents.get(uri) else { return Ok(None); }; let utf8 = self.use_utf8.load(Ordering::Relaxed); let (line, col) = nav::lsp_to_byte_pos(params.text_document_position.position, &doc.text, utf8); // Only fire completion inside an unterminated `[[ ... ` run. if !is_inside_wikilink(&doc.text, line, col) { return Ok(None); } let Some(wiki) = self.wiki_for_uri(uri) else { return Ok(Some(CompletionResponse::Array(Vec::new()))); }; let idx = wiki.index.read().expect("index lock poisoned"); let items: Vec = idx .page_names() .into_iter() .map(|name| CompletionItem { label: name.clone(), kind: Some(CompletionItemKind::FILE), insert_text: Some(name), ..CompletionItem::default() }) .collect(); Ok(Some(CompletionResponse::Array(items))) } async fn rename(&self, params: RenameParams) -> LspResult> { let uri = params.text_document_position.text_document.uri; let pos = params.text_document_position.position; let utf8 = self.use_utf8.load(Ordering::Relaxed); // If the cursor sits on a wikilink, rename the *linked* page; // otherwise rename the buffer's own file. Closed-doc reads happen // inside `compute_rename` via `read_or_open`. let target_uri = self.rename_target_uri(&uri, pos).unwrap_or(uri); let edit = rename::compute_rename(self, target_uri, params.new_name, utf8).await; Ok(edit) } async fn execute_command( &self, params: ExecuteCommandParams, ) -> LspResult> { match commands::execute(self, ¶ms.command, params.arguments).await { Ok(Some(commands::CommandOutcome::Edit(edit))) => { if let Err(e) = self.client.apply_edit(edit).await { self.client .log_message( MessageType::ERROR, format!("nuwiki: apply_edit failed — {e}"), ) .await; } Ok(None) } Ok(Some(commands::CommandOutcome::Value(v))) => Ok(Some(v)), Ok(None) => Ok(None), Err(msg) => { self.client .log_message(MessageType::ERROR, format!("nuwiki: {msg}")) .await; Ok(None) } } } async fn folding_range( &self, params: FoldingRangeParams, ) -> LspResult>> { let uri = ¶ms.text_document.uri; let Some(doc) = self.documents.get(uri) else { return Ok(None); }; let total = folding::line_count(&doc.text); Ok(Some(folding::folding_ranges(&doc.ast, total))) } async fn symbol( &self, params: WorkspaceSymbolParams, ) -> LspResult>> { let q = params.query.to_lowercase(); let wikis = self.wikis.read().expect("wikis lock poisoned").clone(); let mut out = Vec::new(); for wiki in &wikis { let idx = wiki.index.read().expect("index lock poisoned"); for page in idx.pages_by_uri.values() { for h in &page.headings { if q.is_empty() || h.title.to_lowercase().contains(&q) { #[allow(deprecated)] out.push(LspSymbolInformation { name: h.title.clone(), kind: SymbolKind::STRING, tags: None, deprecated: None, location: Location { uri: page.uri.clone(), range: span_to_lsp_range_no_text(&h.span), }, container_name: Some(page.name.clone()), }); } } // Surface tags too, named as `:tag:` so editors // can distinguish them from headings at a glance. for t in &page.tags { if q.is_empty() || t.name.to_lowercase().contains(&q) { #[allow(deprecated)] out.push(LspSymbolInformation { name: format!(":{}:", t.name), kind: SymbolKind::PROPERTY, tags: None, deprecated: None, location: Location { uri: page.uri.clone(), range: span_to_lsp_range_no_text(&t.span), }, container_name: Some(page.name.clone()), }); } } } } Ok(Some(out)) } } /// Same as `synthesise_page_uri` but anchored on `source_uri`'s parent /// directory and the conventional `.wiki` extension. Used when no /// `Wiki` is registered (server received no `initializationOptions` /// and no `workspace_folders`) — `` on a wikilink still opens a /// future page next to the current file. fn synthesise_page_uri_next_to(source_uri: &Url, path: &str) -> Option { let source_path = source_uri.to_file_path().ok()?; let dir = source_path.parent()?; let mut p = dir.to_path_buf(); for segment in path.split('/') { if !segment.is_empty() { p.push(segment); } } let stem = p .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); if !stem.is_empty() && !stem.contains('.') { p.set_file_name(format!("{stem}.wiki")); } Url::from_file_path(p).ok() } /// Build a `/` URI for a wikilink that /// doesn't (yet) resolve to an indexed page. Used by /// `Backend::resolve_target_uri` so `` on `[[Newish]]` opens the /// future page in the editor — saving creates it. Slashes inside `path` /// are honoured for subdirectory pages. fn synthesise_page_uri(cfg: &crate::config::WikiConfig, path: &str) -> Option { if cfg.root.as_os_str().is_empty() { return None; } let mut p = cfg.root.clone(); for segment in path.split('/') { if !segment.is_empty() { p.push(segment); } } let ext = cfg.file_extension.trim_start_matches('.'); if !ext.is_empty() { let current_ext = p.extension().and_then(|e| e.to_str()).unwrap_or(""); if current_ext != ext { let stem = p .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); if !stem.is_empty() { p.set_file_name(format!("{stem}.{ext}")); } } } Url::from_file_path(p).ok() } /// File-operation capability for `.wiki` files. Tells the client to /// notify us before/after the user renames or deletes files via the file /// explorer (or any other out-of-buffer trigger). Filters scope these /// notifications to wiki sources so we don't see every C++ rename in a /// polyglot workspace. fn wiki_file_operations() -> WorkspaceFileOperationsServerCapabilities { let filters = vec![ FileOperationFilter { scheme: Some("file".into()), pattern: FileOperationPattern { glob: "**/*.wiki".into(), matches: Some(FileOperationPatternKind::File), options: None, }, }, FileOperationFilter { scheme: Some("file".into()), pattern: FileOperationPattern { glob: "**/*.md".into(), matches: Some(FileOperationPatternKind::File), options: None, }, }, ]; let opts = || FileOperationRegistrationOptions { filters: filters.clone(), }; WorkspaceFileOperationsServerCapabilities { did_create: None, will_create: None, did_rename: Some(opts()), will_rename: None, did_delete: Some(opts()), will_delete: None, } } fn pack_data(flat: &[u32]) -> Vec { flat.chunks_exact(5) .map(|c| tower_lsp::lsp_types::SemanticToken { delta_line: c[0], delta_start: c[1], length: c[2], token_type: c[3], token_modifiers_bitset: c[4], }) .collect() } // ===== 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::() as u32 } /// Backward-compat wrapper kept for the legacy test surface. New call sites /// should use `collect_diagnostics` so they can opt into link-health /// and other sources. pub fn ast_diagnostics(ast: &DocumentNode, text: &str, utf8: bool) -> Vec { collect_diagnostics( ast, text, None, None, None, &DiagnosticConfig::default(), utf8, ) } /// Composable diagnostic collector. /// /// Sources, each gated by `cfg`: /// /// 1. Parse errors (always on). /// 2. Broken-link warnings — emitted when an index, source /// page name, and `link_severity != Off` are all available. pub fn collect_diagnostics( ast: &DocumentNode, text: &str, uri: Option<&Url>, index: Option<&WorkspaceIndex>, page_name: Option<&str>, cfg: &DiagnosticConfig, utf8: bool, ) -> Vec { let mut out = Vec::new(); walk_blocks_for_errors(&ast.children, text, utf8, &mut out); if let (Some(idx), Some(name)) = (index, page_name) { out.extend(diagnostics::link_health( ast, text, uri, idx, name, cfg.link_severity, utf8, )); } out } fn walk_blocks_for_errors(blocks: &[BlockNode], text: &str, utf8: bool, out: &mut Vec) { 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, ) { 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 { 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 { 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", nuwiki_core::ast::Keyword::Stopped => "STOPPED", } } // ===== Workspace + navigation helpers ===== // // `workspace_root_from_params` logic now lives in // `Config::from_init_params` (see `config.rs`) so workspace // folders and `root_uri` are handled alongside `initializationOptions`. /// Background workspace scan (P8). Walks every `.wiki` file under `root`, /// parses it, and feeds it into the shared index. Wraps the work in a /// `window/workDoneProgress` begin/end pair so editors can show progress. async fn index_workspace( root: PathBuf, index: Arc>, client: Client, registry: Arc, ) { let token = ProgressToken::String("nuwiki-index".into()); // Best-effort: if the client doesn't pre-create progress tokens we just // send the begin/end directly. tower-lsp permits this. let _ = client .send_notification::(ProgressParams { token: token.clone(), value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(WorkDoneProgressBegin { title: "Indexing wiki".into(), cancellable: Some(false), message: None, percentage: None, })), }) .await; let plugin = registry.get("vimwiki"); let files = index::walk_wiki_files(&root); for path in files { let Ok(text) = std::fs::read_to_string(&path) else { continue; }; let Some(plugin) = plugin else { continue }; let ast = plugin.parse(&text); let Ok(uri) = Url::from_file_path(&path) else { continue; }; if let Ok(mut idx) = index.write() { idx.upsert(uri, &ast); } } let _ = client .send_notification::(ProgressParams { token, value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(WorkDoneProgressEnd { message: Some("done".into()), })), }) .await; } /// After the initial workspace scan for `wiki_id` completes, recompute and /// re-publish diagnostics for every open document that lives in that wiki. /// Documents opened before the index was populated would otherwise show /// stale link-health diagnostics flagging every cross-page link as /// missing-page (the lookups happened against a near-empty index). async fn refresh_open_diagnostics_for_wiki( wiki_id: crate::wiki::WikiId, documents: Arc>, wikis: Arc>>, config: Arc>, client: Client, use_utf8: Arc, ) { // Snapshot the open URIs first so we don't hold a DashMap iterator // across the await below. let uris: Vec = documents.iter().map(|e| e.key().clone()).collect(); for uri in uris { // Re-check wiki ownership inside the loop — a workspace/configuration // change could re-shape the wikis list mid-refresh. let Some(wiki) = ({ let snapshot = wikis.read().ok(); snapshot.and_then(|w| w.iter().find(|w| w.id == wiki_id).cloned()) }) else { return; }; if !wiki.contains(&uri) { continue; } // Take a snapshot of the live doc state. If it's gone (closed mid-scan) // skip it. let Some((ast, text, version)) = ({ documents .get(&uri) .map(|d| (d.ast.clone(), d.text.clone(), d.version)) }) else { continue; }; let utf8 = use_utf8.load(Ordering::Relaxed); let diagnostics = { let cfg = config.read().expect("config lock poisoned"); let page_name = index::page_name_from_uri(&uri, Some(&wiki.config.root)); let idx_guard = wiki.index.read().expect("index lock poisoned"); collect_diagnostics( &ast, &text, Some(&uri), Some(&idx_guard), Some(&page_name), &cfg.diagnostic, utf8, ) }; client .publish_diagnostics(uri, diagnostics, Some(version)) .await; } } fn span_to_lsp_range(span: &Span, text: &str, utf8: bool) -> Range { to_lsp_range(span, text, utf8) } fn span_to_lsp_range_no_text(span: &Span) -> Range { Range { start: tower_lsp::lsp_types::Position { line: span.start.line, character: span.start.column, }, end: tower_lsp::lsp_types::Position { line: span.end.line, character: span.end.column, }, } } fn zero_range() -> Range { Range { start: tower_lsp::lsp_types::Position { line: 0, character: 0, }, end: tower_lsp::lsp_types::Position { line: 0, character: 0, }, } } fn heading_range_in(idx: &WorkspaceIndex, uri: &Url, anchor: &str, _utf8: bool) -> Option { let page = idx.page(uri)?; // Headings win over tags when both match — same precedence as // vimwiki: heading anchors are the "canonical" target shape. if let Some(h) = page.find_heading_by_anchor(anchor) { return Some(span_to_lsp_range_no_text(&h.span)); } // Tags-as-anchors. `[[Page#tag-name]]` lands on the // matching `TagNode` on the target page. let t = page.tags.iter().find(|t| t.name == anchor)?; Some(span_to_lsp_range_no_text(&t.span)) } fn is_inside_wikilink(text: &str, line: u32, byte_col: u32) -> bool { // Scan the current line for the most recent unclosed `[[` before the // cursor. let mut cur = 0u32; let mut current_line = 0u32; let bytes = text.as_bytes(); while current_line < line && cur < bytes.len() as u32 { if bytes[cur as usize] == b'\n' { current_line += 1; } cur += 1; } let line_start = cur as usize; let limit = (line_start + byte_col as usize).min(bytes.len()); let segment = &bytes[line_start..limit]; // Find last "[[" and check no "]]" closes it before cursor. let mut open_at: Option = None; let mut i = 0; while i + 1 < segment.len() { if segment[i] == b'[' && segment[i + 1] == b'[' { open_at = Some(i); i += 2; } else if segment[i] == b']' && segment[i + 1] == b']' { open_at = None; i += 2; } else { i += 1; } } open_at.is_some() } fn preview_for(page: &IndexedPage, anchor: Option<&str>) -> String { let mut buf = String::new(); let title = page.title.as_deref().unwrap_or(&page.name); buf.push_str("**"); buf.push_str(title); buf.push_str("**\n\n"); if let Some(anchor) = anchor { if let Some(h) = page.find_heading_by_anchor(anchor) { buf.push_str(&format!("§ {}\n", h.title)); } else { buf.push_str(&format!("(anchor `{anchor}` not found)\n")); } } else if let Some(h) = page.headings.first() { buf.push_str(&format!("§ {}\n", h.title)); } if !page.headings.is_empty() { buf.push_str("\nOutline:\n"); for h in page.headings.iter().take(8) { let indent = " ".repeat((h.level.saturating_sub(1)) as usize); buf.push_str(&format!("{indent}- {}\n", h.title)); } } buf }