Files
nuwiki/crates/nuwiki-lsp/src/lib.rs
T

1287 lines
49 KiB
Rust
Raw Normal View History

//! LSP protocol bridge for nuwiki.
//!
//! `tower-lsp` server that maintains a `DocumentStore` (SPEC §6.10) plus a
//! workspace-wide index (SPEC §6.10 + P8) and exposes every LSP method
//! through Phase 8:
//!
//! - `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 (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).
//! The workspace scan runs in a background tokio task spawned from
//! `initialize` (P8) with `window/workDoneProgress` begin/end events.
pub mod commands;
pub mod config;
2026-05-11 20:49:32 +00:00
pub mod diagnostics;
pub mod diary;
pub mod edits;
pub mod export;
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, 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<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>,
/// Per-wiki state (P11). Vector holds one entry until Phase 18
/// (multi-wiki) lets users add more.
wikis: Arc<RwLock<Vec<Wiki>>>,
/// Server config received via `initializationOptions` and refreshed
/// via `workspace/didChangeConfiguration` (P18).
config: Arc<RwLock<Config>>,
}
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<Wiki> {
let wikis = self.wikis.read().ok()?;
wikis.iter().find(|w| w.id == id).cloned()
}
2026-05-11 20:49:32 +00:00
/// Used by Phase 15 workspace-level commands (`checkLinks`, `findOrphans`)
/// when no URI is supplied — they fall back to the first registered wiki.
fn default_wiki(&self) -> Option<Wiki> {
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<Url> {
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<Wiki> {
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))
}
/// 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 (Phase 13 `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<DocSnapshot> {
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?;
let plugin = self
.registry
.get("vimwiki")
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "vimwiki syntax plugin missing"))?;
let ast = plugin.parse(&text);
Ok(DocSnapshot {
text,
ast,
in_memory: 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 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,
2026-05-11 20:49:32 +00:00
Some(&uri),
Some(&idx_guard),
page_name.as_deref(),
&cfg.diagnostic,
utf8,
)
} else {
2026-05-11 20:49:32 +00:00
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<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.
};
// Build server config from initializationOptions (P18) and the
// workspace folders, then materialise the `Wiki` aggregate (P11).
let cfg = Config::from_init_params(&params);
*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,
// Phase 17 `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()),
}),
..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.
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 client = self.client.clone();
let registry = Arc::clone(&self.registry);
let index = Arc::clone(&wiki.index);
let root = wiki.config.root.clone();
tokio::spawn(async move {
index_workspace(root, index, client, registry).await;
});
}
}
async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
// Apply the new settings to `Config`. Re-indexing on root changes
// is out of scope for Phase 11; for now we just accept the new
// values so later commands see the right diagnostic settings etc.
// Phase 18 (multi-wiki) revisits this with proper rebuild semantics.
let mut cfg = self.config.write().expect("config lock poisoned");
cfg.apply_change(&params.settings);
}
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) {
// Phase 13 advertises 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 &params.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 &params.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) {
// Phase 17: 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.
let uri = params.text_document.uri.clone();
let wiki = match self.wiki_for_uri(&uri) {
Some(w) => w,
None => return,
};
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 = &params.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<Option<DocumentSymbolResponse>> {
let uri = &params.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<Option<SemanticTokensResult>> {
let uri = &params.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<Option<SemanticTokensRangeResult>> {
let uri = &params.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<Option<GotoDefinitionResponse>> {
let pos = params.text_document_position_params.position;
let uri = &params.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) => {
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 idx_guard = wiki
.as_ref()
.map(|w| w.index.read().expect("index lock poisoned"));
let target_uri = idx_guard
.as_ref()
.and_then(|idx| idx.resolve(&w.target, &source_page).cloned());
target_uri.map(|target| {
let target_range = w
.target
.anchor
.as_deref()
.and_then(|a| {
idx_guard
.as_ref()
.and_then(|idx| heading_range_in(idx, &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<Option<Vec<Location>>> {
let pos = params.text_document_position.position;
let uri = &params.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.clone(),
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<Location> = 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<Location> = 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<Option<Hover>> {
let pos = params.text_document_position_params.position;
let uri = &params.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 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 body = if let Some(wiki) = wiki.as_ref() {
let idx = wiki.index.read().expect("index lock poisoned");
let target_uri = idx.resolve(&w.target, &source_page).cloned();
match target_uri.as_ref().and_then(|u| idx.page(u)) {
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(),
},
}
} else {
"(no wiki configured)".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<Option<CompletionResponse>> {
let uri = &params.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<CompletionItem> = 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<Option<WorkspaceEdit>> {
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<Option<serde_json::Value>> {
match commands::execute(self, &params.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 symbol(
&self,
params: WorkspaceSymbolParams,
) -> LspResult<Option<Vec<LspSymbolInformation>>> {
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()),
});
}
}
// Phase 12: 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))
}
}
/// 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<tower_lsp::lsp_types::SemanticToken> {
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::<usize>() as u32
}
/// Backward-compat wrapper kept for v1.0 test surface. New call sites
/// should use `collect_diagnostics` so they can opt into link-health
/// (Phase 15) and other sources.
pub fn ast_diagnostics(ast: &DocumentNode, text: &str, utf8: bool) -> Vec<Diagnostic> {
2026-05-11 20:49:32 +00:00
collect_diagnostics(
ast,
text,
None,
None,
None,
&DiagnosticConfig::default(),
utf8,
)
}
/// Composable diagnostic collector (P11 §12.2.4).
///
/// Sources, each gated by `cfg`:
///
/// 1. Parse errors (always on).
2026-05-11 20:49:32 +00:00
/// 2. Broken-link warnings (Phase 15) — emitted when an index, source
/// page name, and `link_severity != Off` are all available.
pub fn collect_diagnostics(
ast: &DocumentNode,
text: &str,
2026-05-11 20:49:32 +00:00
uri: Option<&Url>,
index: Option<&WorkspaceIndex>,
page_name: Option<&str>,
cfg: &DiagnosticConfig,
utf8: bool,
) -> Vec<Diagnostic> {
let mut out = Vec::new();
walk_blocks_for_errors(&ast.children, text, utf8, &mut out);
2026-05-11 20:49:32 +00:00
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<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",
}
}
// ===== Workspace + navigation helpers =====
//
// `workspace_root_from_params` lived here through Phase 10; Phase 11 lifted
// that logic into `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<RwLock<WorkspaceIndex>>,
client: Client,
registry: Arc<SyntaxRegistry>,
) {
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::<tower_lsp::lsp_types::notification::Progress>(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::<tower_lsp::lsp_types::notification::Progress>(ProgressParams {
token,
value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(WorkDoneProgressEnd {
message: Some("done".into()),
})),
})
.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<Range> {
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.headings.iter().find(|h| h.anchor == anchor) {
return Some(span_to_lsp_range_no_text(&h.span));
}
// Phase 12: 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<usize> = 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.headings.iter().find(|h| h.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
}