diff --git a/README.md b/README.md index 3d20988..366c4f3 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ A Vim/Neovim plugin providing full vimwiki syntax support, implemented as a Rust-based language server (LSP). -> **Status:** Phase 7 (semantic tokens) complete. The server now serves -> `semanticTokens/full` + `/range` with custom `vimwiki*` token types and -> `level1`..`level6` + `centered` modifiers. Navigation features and -> editor glue still pending. +> **Status:** Phase 8 (navigation) complete. The server now serves +> definition, references, hover, completion, `workspace/symbol`, plus a +> background-indexed workspace per P8. Only editor glue and the release +> pipeline are still pending. See [`SPEC.md`](./SPEC.md) for the full project specification. @@ -22,8 +22,8 @@ See [`SPEC.md`](./SPEC.md) for the full project specification. | 5 | Renderer | ✅ done | | 6 | LSP Foundation | ✅ done | | 7 | Semantic Tokens | ✅ done | -| 8 | Navigation | ⏳ next | -| 9 | Editor Glue | ⏳ | +| 8 | Navigation | ✅ done | +| 9 | Editor Glue | ⏳ next | | 10 | CI/CD release pipeline | ⏳ | ## Repository layout diff --git a/SPEC.md b/SPEC.md index 16708a8..ce781cc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,7 +1,7 @@ # nuwiki — Project Specification > Last updated: 2026-05-10 -> Status: Phase 7 (Semantic Tokens) complete — moving to Phase 8 (Navigation) +> Status: Phase 8 (Navigation) complete — moving to Phase 9 (Editor Glue) --- @@ -591,6 +591,6 @@ These decisions are required before or during the phase indicated. | P5 | **Minimum Neovim version** | Phase 9 | 0.8 (`vim.lsp.start`) · 0.11 (`vim.lsp.config`) | 0.8 = broader compat; 0.11 = cleaner Lua API | | P6 | **Minimum Vim version** | Phase 9 | Vim 8.2 · Vim 9.0 · Vim 9.1 | LSP client plugins work best on 9.0+; recommend documenting 9.1 as minimum | | ~~P7~~ | ~~**Semantic token type mapping**~~ | ~~Phase 7~~ | ✅ **Custom vimwiki-specific token types** | ~20 types (`vimwikiHeading`, `vimwikiBold`, …) + `level1`..`level6` + `centered` modifiers. Phase 9 ships default highlight groups in `syntax/nuwiki.vim` and the Lua glue. | -| P8 | **Workspace indexing strategy** | Phase 8 | Eager on startup · Lazy + background | Eager blocks startup on large wikis; recommend lazy with progress notification | +| ~~P8~~ | ~~**Workspace indexing strategy**~~ | ~~Phase 8~~ | ✅ **Lazy + background with progress** | Initial scan runs as a background tokio task; nav features answer with partial data until complete; progress reported via `window/workDoneProgress`. | | P9 | **macOS runner** | Phase 10 | Stay manual · Register macOS runner | Revisit if a Mac is available; no action needed now | diff --git a/crates/nuwiki-lsp/src/index.rs b/crates/nuwiki-lsp/src/index.rs new file mode 100644 index 0000000..c2a6691 --- /dev/null +++ b/crates/nuwiki-lsp/src/index.rs @@ -0,0 +1,365 @@ +//! Workspace index — the in-memory model of every `.wiki` page nuwiki +//! has seen, used to power Phase 8 nav features. +//! +//! Per P8 (SPEC §11), the initial scan runs as a background tokio task so +//! the server stays responsive on startup. Per-document updates land here +//! synchronously via `upsert` whenever the LSP backend re-parses on +//! `didOpen` / `didChange`. +//! +//! The index keeps only the projections nav needs (headings + outgoing +//! links) — the full AST already lives in the backend's `documents` +//! `DashMap`. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use nuwiki_core::ast::{ + BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, LinkTarget, ListNode, Span, + TableNode, +}; +use tower_lsp::lsp_types::Url; + +#[derive(Debug, Clone)] +pub struct IndexedPage { + pub uri: Url, + /// Page name relative to the workspace root, without the `.wiki` + /// extension. Falls back to the URL's last path segment if no root. + pub name: String, + pub title: Option, + pub headings: Vec, + pub outgoing: Vec, +} + +#[derive(Debug, Clone)] +pub struct HeadingInfo { + pub title: String, + pub level: u8, + pub anchor: String, + pub span: Span, +} + +#[derive(Debug, Clone)] +pub struct OutgoingLink { + /// Resolved target page name (for `LinkKind::Wiki`) or `None` for kinds + /// that don't address another page in the workspace (`AnchorOnly`, + /// `Raw`, `File`, `Local`, `Diary`, …). + pub target_page: Option, + pub anchor: Option, + pub kind: LinkKind, + pub span: Span, +} + +#[derive(Debug, Clone)] +pub struct Backlink { + pub source_uri: Url, + pub source_span: Span, + pub target_anchor: Option, +} + +#[derive(Debug, Default)] +pub struct WorkspaceIndex { + pub root: Option, + pub pages_by_uri: HashMap, + pub pages_by_name: HashMap, + pub backlinks: HashMap>, +} + +impl WorkspaceIndex { + pub fn new(root: Option) -> Self { + Self { + root, + ..Default::default() + } + } + + /// Insert or update an indexed page. Removes any prior indexing for + /// the same URI first (handles renames and re-parses without leaking + /// stale backlinks). + pub fn upsert(&mut self, uri: Url, ast: &DocumentNode) { + self.remove(&uri); + let name = page_name_from_uri(&uri, self.root.as_deref()); + let mut page = IndexedPage { + uri: uri.clone(), + name: name.clone(), + title: ast.metadata.title.clone(), + headings: Vec::new(), + outgoing: Vec::new(), + }; + index_blocks(&ast.children, &mut page); + + for link in &page.outgoing { + if let Some(tgt) = &link.target_page { + self.backlinks + .entry(tgt.clone()) + .or_default() + .push(Backlink { + source_uri: uri.clone(), + source_span: link.span, + target_anchor: link.anchor.clone(), + }); + } + } + + self.pages_by_name.insert(name, uri.clone()); + self.pages_by_uri.insert(uri, page); + } + + pub fn remove(&mut self, uri: &Url) { + if let Some(page) = self.pages_by_uri.remove(uri) { + self.pages_by_name.remove(&page.name); + } + for entries in self.backlinks.values_mut() { + entries.retain(|b| &b.source_uri != uri); + } + self.backlinks.retain(|_, v| !v.is_empty()); + } + + /// Resolve a `LinkTarget` to a page URI in this workspace. + /// `source_page` is the name of the page the link originates from — + /// needed for `AnchorOnly` resolution. + pub fn resolve(&self, target: &LinkTarget, source_page: &str) -> Option<&Url> { + match target.kind { + LinkKind::Wiki => target + .path + .as_deref() + .and_then(|p| self.pages_by_name.get(p)), + LinkKind::AnchorOnly => self.pages_by_name.get(source_page), + // Interwiki / Diary / File / Local / Raw aren't resolved + // against the workspace index here. The handlers fall back to + // best-effort resolution (e.g. building file:// URLs). + _ => None, + } + } + + pub fn page(&self, uri: &Url) -> Option<&IndexedPage> { + self.pages_by_uri.get(uri) + } + + pub fn page_by_name(&self, name: &str) -> Option<&IndexedPage> { + self.pages_by_name + .get(name) + .and_then(|u| self.pages_by_uri.get(u)) + } + + pub fn page_names(&self) -> Vec { + let mut v: Vec = self.pages_by_name.keys().cloned().collect(); + v.sort(); + v + } + + pub fn backlinks_for(&self, page_name: &str) -> &[Backlink] { + self.backlinks + .get(page_name) + .map(Vec::as_slice) + .unwrap_or(&[]) + } +} + +/// Derive a page name from a `file://` URL. With a workspace root the name +/// is the URL's path relative to root, sans `.wiki`. Without a root it's +/// just the filename stem. +pub fn page_name_from_uri(uri: &Url, root: Option<&Path>) -> String { + if let (Some(root), Ok(p)) = (root, uri.to_file_path()) { + if let Ok(rel) = p.strip_prefix(root) { + let stripped = rel.with_extension(""); + return stripped + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/"); + } + } + // Fallback: last path segment, strip .wiki if present. + if let Ok(p) = uri.to_file_path() { + return p + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + } + uri.path_segments() + .and_then(|mut s| s.next_back()) + .map(|s| { + s.strip_suffix(".wiki") + .unwrap_or(s) + .trim_start_matches('/') + .to_string() + }) + .unwrap_or_default() +} + +/// Normalise heading text into a URL-safe anchor slug. +pub fn slugify(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut prev_dash = false; + for ch in s.chars() { + if ch.is_alphanumeric() { + for c in ch.to_lowercase() { + out.push(c); + } + prev_dash = false; + } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !prev_dash && !out.is_empty() { + out.push('-'); + prev_dash = true; + } + // other punctuation: drop + } + while out.ends_with('-') { + out.pop(); + } + out +} + +// ===== Indexing helpers ===== + +fn index_blocks(blocks: &[BlockNode], page: &mut IndexedPage) { + for block in blocks { + index_block(block, page); + } +} + +fn index_block(block: &BlockNode, page: &mut IndexedPage) { + match block { + BlockNode::Heading(h) => { + let title = inline_to_text(&h.children); + page.headings.push(HeadingInfo { + title: title.clone(), + level: h.level, + anchor: slugify(&title), + span: h.span, + }); + index_inlines(&h.children, page); + } + BlockNode::Paragraph(p) => index_inlines(&p.children, page), + BlockNode::Blockquote(BlockquoteNode { children, .. }) => { + for c in children { + index_block(c, page); + } + } + BlockNode::List(ListNode { items, .. }) => { + for item in items { + index_inlines(&item.children, page); + if let Some(sub) = &item.sublist { + index_block(&BlockNode::List(sub.clone()), page); + } + } + } + BlockNode::DefinitionList(dl) => { + for item in &dl.items { + if let Some(term) = &item.term { + index_inlines(term, page); + } + for def in &item.definitions { + index_inlines(def, page); + } + } + } + BlockNode::Table(TableNode { rows, .. }) => { + for row in rows { + for cell in &row.cells { + index_inlines(&cell.children, page); + } + } + } + _ => {} + } +} + +fn index_inlines(inlines: &[InlineNode], page: &mut IndexedPage) { + for n in inlines { + match n { + InlineNode::WikiLink(w) => { + page.outgoing.push(OutgoingLink { + target_page: w.target.path.clone(), + anchor: w.target.anchor.clone(), + kind: w.target.kind, + span: w.span, + }); + if let Some(desc) = &w.description { + index_inlines(desc, page); + } + } + InlineNode::ExternalLink(e) => { + if let Some(desc) = &e.description { + index_inlines(desc, page); + } + } + InlineNode::Bold(b) => index_inlines(&b.children, page), + InlineNode::Italic(i) => index_inlines(&i.children, page), + InlineNode::BoldItalic(bi) => index_inlines(&bi.children, page), + InlineNode::Strikethrough(s) => index_inlines(&s.children, page), + InlineNode::Superscript(s) => index_inlines(&s.children, page), + InlineNode::Subscript(s) => index_inlines(&s.children, page), + InlineNode::Color(c) => index_inlines(&c.children, page), + _ => {} + } + } +} + +fn inline_to_text(inlines: &[InlineNode]) -> String { + let mut out = String::new(); + inline_to_text_into(inlines, &mut out); + out.trim().to_string() +} + +fn inline_to_text_into(inlines: &[InlineNode], out: &mut String) { + for n in inlines { + match n { + InlineNode::Text(t) => out.push_str(&t.content), + InlineNode::Bold(b) => inline_to_text_into(&b.children, out), + InlineNode::Italic(i) => inline_to_text_into(&i.children, out), + InlineNode::BoldItalic(bi) => inline_to_text_into(&bi.children, out), + InlineNode::Strikethrough(s) => inline_to_text_into(&s.children, out), + InlineNode::Code(c) => out.push_str(&c.content), + InlineNode::Superscript(s) => inline_to_text_into(&s.children, out), + InlineNode::Subscript(s) => inline_to_text_into(&s.children, out), + InlineNode::MathInline(m) => out.push_str(&m.content), + InlineNode::Color(c) => inline_to_text_into(&c.children, out), + InlineNode::WikiLink(w) => { + if let Some(d) = &w.description { + inline_to_text_into(d, out); + } else if let Some(p) = &w.target.path { + out.push_str(p); + } + } + InlineNode::ExternalLink(e) => match &e.description { + Some(d) => inline_to_text_into(d, out), + None => out.push_str(&e.url), + }, + InlineNode::Keyword(_) => {} + InlineNode::Transclusion(t) => out.push_str(t.alt.as_deref().unwrap_or(&t.url)), + InlineNode::RawUrl(r) => out.push_str(&r.url), + } + } +} + +// ===== Filesystem walking ===== + +/// Recursively collect every `.wiki` file under `root`, skipping dotfiles +/// and dot-directories. +pub fn walk_wiki_files(root: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name() else { + continue; + }; + if name.to_string_lossy().starts_with('.') { + continue; + } + let Ok(meta) = entry.metadata() else { + continue; + }; + if meta.is_dir() { + stack.push(path); + } else if meta.is_file() && path.extension().and_then(|e| e.to_str()) == Some("wiki") { + out.push(path); + } + } + } + out.sort(); + out +} diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index d00aa88..e566f46 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -1,36 +1,52 @@ //! LSP protocol bridge for nuwiki. //! -//! `tower-lsp` server that maintains a `DocumentStore` (SPEC §6.10) and -//! exposes the Phase 6 feature set: +//! `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 index; +pub mod nav; pub mod semantic_tokens; use std::io; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +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, Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, - InitializeParams, InitializeResult, InitializedParams, MessageType, OneOf, - PositionEncodingKind, SemanticTokens, SemanticTokensFullOptions, SemanticTokensOptions, - SemanticTokensParams, SemanticTokensRangeParams, SemanticTokensRangeResult, - SemanticTokensResult, SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo, - SymbolKind, TextDocumentSyncCapability, TextDocumentSyncKind, Url, WorkDoneProgressOptions, + GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents, HoverParams, + HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams, Location, + MarkupContent, MarkupKind, MessageType, OneOf, PositionEncodingKind, ProgressParams, + ProgressParamsValue, ProgressToken, Range, ReferenceParams, SemanticTokens, + SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams, + SemanticTokensRangeParams, SemanticTokensRangeResult, SemanticTokensResult, + SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo, + SymbolInformation as LspSymbolInformation, SymbolKind, TextDocumentSyncCapability, + TextDocumentSyncKind, Url, WorkDoneProgress, WorkDoneProgressBegin, WorkDoneProgressEnd, + WorkDoneProgressOptions, WorkspaceSymbolParams, }; use tower_lsp::{Client, LanguageServer, LspService, Server}; @@ -41,6 +57,8 @@ use nuwiki_core::ast::{ use nuwiki_core::syntax::vimwiki::VimwikiSyntax; use nuwiki_core::syntax::SyntaxRegistry; +use crate::index::{IndexedPage, WorkspaceIndex}; + /// 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) @@ -78,6 +96,8 @@ struct Backend { /// 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, + /// Workspace index — populated lazily in the background per P8. + index: Arc>, } impl Backend { @@ -89,6 +109,7 @@ impl Backend { documents: Arc::new(DashMap::new()), registry: Arc::new(registry), use_utf8: Arc::new(AtomicBool::new(false)), + index: Arc::new(RwLock::new(WorkspaceIndex::default())), } } @@ -106,6 +127,13 @@ impl Backend { }; let ast = plugin.parse(&text); let diagnostics = ast_diagnostics(&ast, &text, self.use_utf8.load(Ordering::Relaxed)); + + // Keep the workspace index in sync with what the editor is showing. + { + let mut idx = self.index.write().expect("index lock poisoned"); + idx.upsert(uri.clone(), &ast); + } + self.documents .insert(uri.clone(), DocumentState { text, ast, version }); self.client @@ -133,6 +161,12 @@ impl LanguageServer for Backend { None // server defaults to UTF-16 per LSP 3.16. }; + // Capture the workspace root for the background indexer. + let root = workspace_root_from_params(¶ms); + if let Some(p) = &root { + self.index.write().expect("index lock poisoned").root = Some(p.clone()); + } + Ok(InitializeResult { capabilities: ServerCapabilities { position_encoding, @@ -140,6 +174,14 @@ impl LanguageServer for Backend { TextDocumentSyncKind::FULL, )), 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)), + hover_provider: Some(HoverProviderCapability::Simple(true)), + completion_provider: Some(CompletionOptions { + trigger_characters: Some(vec!["[".into()]), + ..CompletionOptions::default() + }), semantic_tokens_provider: Some( SemanticTokensServerCapabilities::SemanticTokensOptions( SemanticTokensOptions { @@ -171,6 +213,17 @@ impl LanguageServer for Backend { format!("nuwiki-lsp initialized (positionEncoding={enc})"), ) .await; + + // Kick off the background workspace scan (P8). Skips if no root. + let root = self.index.read().expect("index lock poisoned").root.clone(); + if let Some(root) = root { + let index = Arc::clone(&self.index); + let client = self.client.clone(); + let registry = Arc::clone(&self.registry); + tokio::spawn(async move { + index_workspace(root, index, client, registry).await; + }); + } } async fn shutdown(&self) -> LspResult<()> { @@ -194,10 +247,13 @@ impl LanguageServer for Backend { } async fn did_close(&self, params: DidCloseTextDocumentParams) { - self.documents.remove(¶ms.text_document.uri); + 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, Vec::new(), None) + .publish_diagnostics(params.text_document.uri.clone(), Vec::new(), None) .await; } @@ -245,6 +301,211 @@ impl LanguageServer for Backend { 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) => { + let source_page = index::page_name_from_uri( + uri, + self.index + .read() + .expect("index lock poisoned") + .root + .as_deref(), + ); + let idx = self.index.read().expect("index lock poisoned"); + let target_uri = idx.resolve(&w.target, &source_page).cloned(); + target_uri.map(|target| { + let target_range = w + .target + .anchor + .as_deref() + .and_then(|a| 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>> { + 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 source_page = index::page_name_from_uri( + uri, + self.index + .read() + .expect("index lock poisoned") + .root + .as_deref(), + ); + 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 idx = self.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 source_page = index::page_name_from_uri( + uri, + self.index + .read() + .expect("index lock poisoned") + .root + .as_deref(), + ); + let idx = self.index.read().expect("index lock poisoned"); + let target_uri = idx.resolve(&w.target, &source_page).cloned(); + let body = 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(), + }, + }; + (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 idx = self.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 symbol( + &self, + params: WorkspaceSymbolParams, + ) -> LspResult>> { + let q = params.query.to_lowercase(); + let idx = self.index.read().expect("index lock poisoned"); + let mut out = Vec::new(); + 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()), + }); + } + } + } + Ok(Some(out)) + } } fn pack_data(flat: &[u32]) -> Vec { @@ -469,3 +730,165 @@ fn keyword_str(k: nuwiki_core::ast::Keyword) -> &'static str { nuwiki_core::ast::Keyword::Xxx => "XXX", } } + +// ===== Workspace + navigation helpers ===== + +fn workspace_root_from_params(params: &InitializeParams) -> Option { + if let Some(folders) = ¶ms.workspace_folders { + if let Some(first) = folders.first() { + if let Ok(p) = first.uri.to_file_path() { + return Some(p); + } + } + } + #[allow(deprecated)] + if let Some(uri) = ¶ms.root_uri { + if let Ok(p) = uri.to_file_path() { + return Some(p); + } + } + None +} + +/// 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; +} + +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)?; + let h = page.headings.iter().find(|h| h.anchor == anchor)?; + Some(span_to_lsp_range_no_text(&h.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.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 +} diff --git a/crates/nuwiki-lsp/src/nav.rs b/crates/nuwiki-lsp/src/nav.rs new file mode 100644 index 0000000..55941eb --- /dev/null +++ b/crates/nuwiki-lsp/src/nav.rs @@ -0,0 +1,149 @@ +//! Helpers shared by the Phase 8 navigation handlers (definition, +//! references, hover, completion). +//! +//! Two responsibilities: +//! +//! 1. **Position conversion** — clients send LSP `Position`s in either +//! UTF-8 or UTF-16 encoding; nuwiki AST spans are byte offsets. +//! `lsp_to_byte_pos` translates the LSP position into the same shape +//! so it can be compared against AST spans. +//! 2. **Position lookup** — given a byte position, find the most specific +//! `InlineNode` whose span covers it. Used to identify the link under +//! the cursor. + +use nuwiki_core::ast::{BlockNode, DocumentNode, InlineNode, ListItemNode, Span}; +use tower_lsp::lsp_types::Position as LspPosition; + +use crate::semantic_tokens::LineIndex; + +/// Translate an LSP `Position` (whose `character` is a UTF-16 code-unit +/// offset by default, or a UTF-8 byte offset when negotiated) into our +/// internal `(line, byte_col)` shape. +pub fn lsp_to_byte_pos(p: LspPosition, text: &str, utf8: bool) -> (u32, u32) { + if utf8 { + return (p.line, p.character); + } + let idx = LineIndex::new(text); + let line_str = idx.line_str(p.line, text); + let byte_col = utf16_to_byte_col(line_str, p.character); + (p.line, byte_col) +} + +fn utf16_to_byte_col(line: &str, target_utf16: u32) -> u32 { + let mut byte = 0u32; + let mut units = 0u32; + for ch in line.chars() { + if units >= target_utf16 { + break; + } + units += ch.len_utf16() as u32; + byte += ch.len_utf8() as u32; + } + byte +} + +/// Find the most specific inline node whose span covers the given byte +/// position. Walks block structure first, then descends into inline +/// containers (bold, italic, …) until no child claims the position. +pub fn find_inline_at(doc: &DocumentNode, line: u32, byte_col: u32) -> Option<&InlineNode> { + for block in &doc.children { + if let Some(node) = find_in_block(block, line, byte_col) { + return Some(node); + } + } + None +} + +fn find_in_block(block: &BlockNode, line: u32, col: u32) -> Option<&InlineNode> { + match block { + BlockNode::Heading(h) => find_in_inlines(&h.children, line, col), + BlockNode::Paragraph(p) => find_in_inlines(&p.children, line, col), + BlockNode::Blockquote(b) => b.children.iter().find_map(|c| find_in_block(c, line, col)), + BlockNode::List(l) => l + .items + .iter() + .find_map(|it| find_in_list_item(it, line, col)), + BlockNode::DefinitionList(dl) => dl.items.iter().find_map(|it| { + it.term + .as_ref() + .and_then(|t| find_in_inlines(t, line, col)) + .or_else(|| { + it.definitions + .iter() + .find_map(|d| find_in_inlines(d, line, col)) + }) + }), + BlockNode::Table(t) => t.rows.iter().find_map(|r| { + r.cells + .iter() + .find_map(|c| find_in_inlines(&c.children, line, col)) + }), + _ => None, + } +} + +fn find_in_list_item(item: &ListItemNode, line: u32, col: u32) -> Option<&InlineNode> { + find_in_inlines(&item.children, line, col).or_else(|| { + item.sublist.as_ref().and_then(|sub| { + sub.items + .iter() + .find_map(|i| find_in_list_item(i, line, col)) + }) + }) +} + +fn find_in_inlines(inlines: &[InlineNode], line: u32, col: u32) -> Option<&InlineNode> { + for n in inlines { + if !span_contains(span_of_inline(n), line, col) { + continue; + } + // Descend into inline containers so we return the *innermost* hit. + let deeper = match n { + InlineNode::Bold(b) => find_in_inlines(&b.children, line, col), + InlineNode::Italic(i) => find_in_inlines(&i.children, line, col), + InlineNode::BoldItalic(bi) => find_in_inlines(&bi.children, line, col), + InlineNode::Strikethrough(s) => find_in_inlines(&s.children, line, col), + InlineNode::Superscript(s) => find_in_inlines(&s.children, line, col), + InlineNode::Subscript(s) => find_in_inlines(&s.children, line, col), + InlineNode::Color(c) => find_in_inlines(&c.children, line, col), + InlineNode::WikiLink(w) => w + .description + .as_ref() + .and_then(|d| find_in_inlines(d, line, col)), + InlineNode::ExternalLink(e) => e + .description + .as_ref() + .and_then(|d| find_in_inlines(d, line, col)), + _ => None, + }; + return Some(deeper.unwrap_or(n)); + } + None +} + +fn span_contains(s: Span, line: u32, col: u32) -> bool { + let start = (s.start.line, s.start.column); + let end = (s.end.line, s.end.column); + let p = (line, col); + p >= start && p < end +} + +pub fn span_of_inline(node: &InlineNode) -> Span { + match node { + InlineNode::Text(n) => n.span, + InlineNode::Bold(n) => n.span, + InlineNode::Italic(n) => n.span, + InlineNode::BoldItalic(n) => n.span, + InlineNode::Strikethrough(n) => n.span, + InlineNode::Code(n) => n.span, + InlineNode::Superscript(n) => n.span, + InlineNode::Subscript(n) => n.span, + InlineNode::MathInline(n) => n.span, + InlineNode::Keyword(n) => n.span, + InlineNode::Color(n) => n.span, + InlineNode::WikiLink(n) => n.span, + InlineNode::ExternalLink(n) => n.span, + InlineNode::Transclusion(n) => n.span, + InlineNode::RawUrl(n) => n.span, + } +} diff --git a/crates/nuwiki-lsp/tests/nav.rs b/crates/nuwiki-lsp/tests/nav.rs new file mode 100644 index 0000000..5cf0ff3 --- /dev/null +++ b/crates/nuwiki-lsp/tests/nav.rs @@ -0,0 +1,228 @@ +//! Tests for the Phase 8 navigation pieces: WorkspaceIndex queries, +//! page-name derivation, anchor slugify, position lookup, and link +//! resolution. The async LSP handlers are exercised through these +//! helpers; end-to-end JSON-RPC drive lives in a follow-up phase. + +use std::path::PathBuf; + +use nuwiki_core::ast::{InlineNode, LinkKind}; +use nuwiki_core::syntax::vimwiki::VimwikiSyntax; +use nuwiki_core::syntax::SyntaxPlugin; +use nuwiki_lsp::index::{page_name_from_uri, slugify, WorkspaceIndex}; +use nuwiki_lsp::nav::{find_inline_at, lsp_to_byte_pos}; +use tower_lsp::lsp_types::{Position as LspPosition, Url}; + +fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { + VimwikiSyntax::new().parse(src) +} + +// ===== Slugify ===== + +#[test] +fn slugify_basic_title() { + assert_eq!(slugify("Hello World"), "hello-world"); +} + +#[test] +fn slugify_strips_punctuation_and_collapses_dashes() { + assert_eq!(slugify("Hello, World! Again"), "hello-world-again"); +} + +#[test] +fn slugify_unicode_lowercase() { + assert_eq!(slugify("Crème Brûlée"), "crème-brûlée"); +} + +#[test] +fn slugify_no_trailing_dash() { + assert_eq!(slugify("Title — "), "title"); +} + +// ===== Page name from URI ===== + +#[test] +fn page_name_relative_to_root() { + let root = PathBuf::from("/wiki"); + let uri = Url::from_file_path("/wiki/dir/Page.wiki").unwrap(); + assert_eq!(page_name_from_uri(&uri, Some(&root)), "dir/Page"); +} + +#[test] +fn page_name_at_root() { + let root = PathBuf::from("/wiki"); + let uri = Url::from_file_path("/wiki/Index.wiki").unwrap(); + assert_eq!(page_name_from_uri(&uri, Some(&root)), "Index"); +} + +#[test] +fn page_name_without_root_falls_back_to_stem() { + let uri = Url::from_file_path("/tmp/foo.wiki").unwrap(); + assert_eq!(page_name_from_uri(&uri, None), "foo"); +} + +// ===== Workspace index ===== + +#[test] +fn upsert_indexes_headings_and_outgoing_links() { + let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); + let uri = Url::from_file_path("/wiki/Home.wiki").unwrap(); + let ast = parse("= Home =\n[[Other]] and [[#Section]]\n"); + idx.upsert(uri.clone(), &ast); + + let page = idx.page(&uri).expect("page indexed"); + assert_eq!(page.name, "Home"); + assert_eq!(page.headings.len(), 1); + assert_eq!(page.headings[0].title, "Home"); + assert_eq!(page.headings[0].anchor, "home"); + assert_eq!(page.outgoing.len(), 2); + assert_eq!(page.outgoing[0].target_page.as_deref(), Some("Other")); + assert_eq!(page.outgoing[0].kind, LinkKind::Wiki); + assert_eq!(page.outgoing[1].kind, LinkKind::AnchorOnly); +} + +#[test] +fn upsert_registers_page_by_name_and_records_backlinks() { + let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); + let home = Url::from_file_path("/wiki/Home.wiki").unwrap(); + let about = Url::from_file_path("/wiki/About.wiki").unwrap(); + + idx.upsert(home.clone(), &parse("[[About]]\n")); + idx.upsert(about.clone(), &parse("= About =\n")); + + assert_eq!(idx.page_by_name("About").unwrap().uri, about); + + let backlinks = idx.backlinks_for("About"); + assert_eq!(backlinks.len(), 1); + assert_eq!(backlinks[0].source_uri, home); +} + +#[test] +fn upsert_replaces_prior_indexing_for_same_uri() { + let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); + let uri = Url::from_file_path("/wiki/Home.wiki").unwrap(); + idx.upsert(uri.clone(), &parse("[[A]]\n")); + assert_eq!(idx.backlinks_for("A").len(), 1); + + // Re-upsert with different content: old backlinks must drop. + idx.upsert(uri.clone(), &parse("[[B]]\n")); + assert_eq!(idx.backlinks_for("A").len(), 0); + assert_eq!(idx.backlinks_for("B").len(), 1); +} + +#[test] +fn remove_drops_page_and_backlinks() { + let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); + let home = Url::from_file_path("/wiki/Home.wiki").unwrap(); + let about = Url::from_file_path("/wiki/About.wiki").unwrap(); + idx.upsert(home.clone(), &parse("[[About]]\n")); + idx.upsert(about.clone(), &parse("= About =\n")); + + idx.remove(&home); + assert!(idx.page(&home).is_none()); + assert!(idx.backlinks_for("About").is_empty()); + // The target page is untouched. + assert!(idx.page(&about).is_some()); +} + +#[test] +fn resolve_wiki_link_to_uri() { + let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); + let about = Url::from_file_path("/wiki/About.wiki").unwrap(); + idx.upsert(about.clone(), &parse("= About =\n")); + + let target = nuwiki_core::ast::LinkTarget { + kind: LinkKind::Wiki, + path: Some("About".into()), + ..Default::default() + }; + assert_eq!(idx.resolve(&target, "Home"), Some(&about)); +} + +#[test] +fn resolve_anchor_only_link_returns_source_page() { + let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); + let home = Url::from_file_path("/wiki/Home.wiki").unwrap(); + idx.upsert(home.clone(), &parse("= H =\n[[#Section]]\n")); + + let target = nuwiki_core::ast::LinkTarget { + kind: LinkKind::AnchorOnly, + anchor: Some("section".into()), + ..Default::default() + }; + assert_eq!(idx.resolve(&target, "Home"), Some(&home)); +} + +#[test] +fn page_names_lists_indexed_pages_sorted() { + let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); + for name in ["Charlie", "Alpha", "Bravo"] { + let uri = Url::from_file_path(format!("/wiki/{name}.wiki")).unwrap(); + idx.upsert(uri, &parse(&format!("= {name} =\n"))); + } + let names = idx.page_names(); + assert_eq!(names, vec!["Alpha", "Bravo", "Charlie"]); +} + +// ===== Position conversion ===== + +#[test] +fn lsp_to_byte_pos_passthrough_in_utf8() { + let p = LspPosition { + line: 2, + character: 5, + }; + let (line, col) = lsp_to_byte_pos(p, "ignored", true); + assert_eq!((line, col), (2, 5)); +} + +#[test] +fn lsp_to_byte_pos_converts_from_utf16() { + // 'é' is one UTF-16 code unit, two UTF-8 bytes. Char "2" after 'é': + // UTF-16 col 2 → byte col 3. + let text = "héllo\n"; + let p = LspPosition { + line: 0, + character: 2, + }; + let (line, col) = lsp_to_byte_pos(p, text, false); + assert_eq!(line, 0); + assert_eq!(col, 3); +} + +// ===== Position lookup ===== + +#[test] +fn find_inline_at_returns_wikilink_under_cursor() { + let src = "see [[Other]] now\n"; + let doc = parse(src); + // Cursor inside "Other" — byte col 6 falls inside "[[Other]]" (which + // starts at byte 4 and ends at byte 13). + let node = find_inline_at(&doc, 0, 6).expect("wikilink at cursor"); + assert!(matches!(node, InlineNode::WikiLink(_))); +} + +#[test] +fn find_inline_at_returns_none_for_plain_text() { + let src = "just text\n"; + let doc = parse(src); + let node = find_inline_at(&doc, 0, 3); + // "just text" → Text node, which is what we get back from the inner + // walk (not None). Verify it's a Text. + assert!(matches!(node, Some(InlineNode::Text(_)))); +} + +#[test] +fn find_inline_at_descends_into_inline_containers() { + let src = "*bold text*\n"; + let doc = parse(src); + // Inside "bold" — should return the inner Text, not the wrapping Bold. + let node = find_inline_at(&doc, 0, 3).expect("hit"); + assert!(matches!(node, InlineNode::Text(_))); +} + +#[test] +fn find_inline_at_returns_none_past_eol() { + let src = "short\n"; + let doc = parse(src); + assert!(find_inline_at(&doc, 0, 999).is_none()); +}