From ea574f23f8de5689eb37f86af2258fd0db7b4b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Mon, 11 May 2026 14:32:22 +0000 Subject: [PATCH] =?UTF-8?q?phase=2011:=20plumbing=20prereqs=20=E2=80=94=20?= =?UTF-8?q?Wiki,=20edits,=20config,=20snapshot,=20diags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-cutting infrastructure that every v1.1 phase depends on. Lands as a numbered phase before any user-visible feature so Phases 12–19 stay mechanical. 5 pieces: - `config.rs` — `Config` + `WikiConfig` + `DiagnosticConfig` + `LinkSeverity`. Serde-deserialised from `initialization_options`. v1.0 single-wiki shape (`wiki_root` + `file_extension` + `syntax`) desugars to one-entry `wikis = [...]` per P16. `~/...` expansion without pulling in `dirs`. Fallbacks: workspace_folders[0] → deprecated root_uri → empty list. - `wiki.rs` — `Wiki` aggregate (id + WikiConfig + Arc>). `build_wikis(&[WikiConfig])` materialises the list; `resolve_uri_to_wiki` picks the longest-prefix root match so nested wikis route correctly. Single-entry today, multi-entry once Phase 18 lands. - `edits.rs` — `WorkspaceEditBuilder` + `text_edit_replace/insert/delete` + `op_rename/delete/create`. Handles the UTF-8/UTF-16 conversion at the span boundary so each Phase 13+ command stays a 3-liner. Builder picks `documentChanges` when file ops are present (LSP requires it), `changes` map otherwise (works for LSP 3.15- clients). File ops precede content edits in the output so created/renamed files exist when edits apply. `DeleteFile` is built without `annotation_id` — lsp-types 0.94.x ships it that way; later versions added the field. - `Backend` refactor — `index: Arc>` replaced by `wikis: Arc>>` + `config: Arc>`. New `wiki(id)` / `default_wiki()` / `wiki_for_uri(uri)` helpers. Every v1.0 handler (definition, references, hover, completion, workspace/symbol, update_document) goes through `wiki_for_uri`. `initialize` builds wikis from `Config::from_init_params`; `initialized` spawns one indexer task per wiki with the existing `window/workDoneProgress` wrapper. New `did_change_configuration` handler re-applies settings (rebuild semantics revisited in Phase 18). The old `workspace_root_from_params` helper is gone. - `read_or_open` + `DocSnapshot` (scaffolded, `#[allow(dead_code)]` until Phase 13's `workspace/rename` consumes it). Returns the live document when held in the documents map, otherwise async-reads from disk and re-parses so cross-document operations get accurate text and spans instead of trusting stale `WorkspaceIndex` data. - `collect_diagnostics` composable collector. Same `ErrorNode` walk today; takes `Option<&WorkspaceIndex>` + `page_name` + `&DiagnosticConfig` so Phase 15 can drop a link-health source in without further refactor. `ast_diagnostics` kept as a thin wrapper for the v1.0 test surface. New deps: `serde` + `serde_json` (already transitive through tower-lsp; elevated to direct deps for clarity and stability). `tokio` gains the `fs` feature for `read_or_open`'s async disk read. Tests (19 new in `phase11_plumbing.rs`): - Edits: UTF-8 passthrough + UTF-16 conversion (`héllo`), insert/delete shape, builder mode selection, file-op accumulation, `op_create` round-trip - Config: defaults, v1.0 desugar, explicit list overrides legacy, empty JSON, invalid JSON preserves prior state - Wiki: sequential id assignment, longest-prefix resolution, no-match, `Wiki::contains` - Diagnostics: error-node composition, link-health stub returns empty in Phase 11 All 172 v1.0 tests still pass — backward compatible. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 2 + README.md | 25 +- crates/nuwiki-lsp/Cargo.toml | 4 +- crates/nuwiki-lsp/src/config.rs | 216 ++++++++++++++ crates/nuwiki-lsp/src/edits.rs | 159 ++++++++++ crates/nuwiki-lsp/src/lib.rs | 314 ++++++++++++++------ crates/nuwiki-lsp/src/wiki.rs | 70 +++++ crates/nuwiki-lsp/tests/phase11_plumbing.rs | 260 ++++++++++++++++ 8 files changed, 954 insertions(+), 96 deletions(-) create mode 100644 crates/nuwiki-lsp/src/config.rs create mode 100644 crates/nuwiki-lsp/src/edits.rs create mode 100644 crates/nuwiki-lsp/src/wiki.rs create mode 100644 crates/nuwiki-lsp/tests/phase11_plumbing.rs diff --git a/Cargo.lock b/Cargo.lock index b8a6d4b..83bc407 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -393,6 +393,8 @@ dependencies = [ "async-trait", "dashmap 6.1.0", "nuwiki-core", + "serde", + "serde_json", "tokio", "tower-lsp", ] diff --git a/README.md b/README.md index d61ec72..b776c2d 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,18 @@ A Vim/Neovim plugin providing full vimwiki syntax support, implemented as a Rust-based language server (LSP). -> **Status:** All phases (0–10) complete. v1 feature set landed: -> editor-independent core, vimwiki lexer + parser, HTML renderer, -> tower-lsp server with diagnostics + outline + semantic tokens + -> navigation + workspace index, plugin glue, and release pipeline. +> **Status:** v1.0 (Phases 0–10) shipped — parsing, highlighting, navigation, +> editor glue, and release pipeline are all live. v1.1 (Phases 11–19) in +> progress, closing the gap to a full vimwiki replacement: tags, diary, +> server-side edit commands, link health, HTML export commands, multi-wiki, +> and the `:Vimwiki*` keymap layer. See [`SPEC.md`](./SPEC.md) for the full project specification. ## Implementation status +### v1.0 — foundation + | Phase | Name | Status | |---|---|---| | 0 | Scaffolding | ✅ done | @@ -26,6 +29,20 @@ See [`SPEC.md`](./SPEC.md) for the full project specification. | 9 | Editor Glue | ✅ done | | 10 | CI/CD release pipeline | ✅ done | +### v1.1 — full vimwiki replacement + +| Phase | Name | Status | +|---|---|---| +| 11 | Plumbing prerequisites | ✅ done | +| 12 | Tags | ⏳ | +| 13 | Workspace edits + executeCommand | ⏳ | +| 14 | List & table edit commands | ⏳ | +| 15 | Link health + TOC/index generation | ⏳ | +| 16 | Diary | ⏳ | +| 17 | HTML export commands | ⏳ | +| 18 | Multi-wiki | ⏳ | +| 19 | Editor glue v2 (`:Vimwiki*` compat, keymaps, text objects, folding) | ⏳ | + ## Repository layout ``` diff --git a/crates/nuwiki-lsp/Cargo.toml b/crates/nuwiki-lsp/Cargo.toml index 60210c0..172fd05 100644 --- a/crates/nuwiki-lsp/Cargo.toml +++ b/crates/nuwiki-lsp/Cargo.toml @@ -15,6 +15,8 @@ workspace = true [dependencies] nuwiki-core = { path = "../nuwiki-core", version = "0.1.0" } tower-lsp = "0.20" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync", "fs"] } dashmap = "6" async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/nuwiki-lsp/src/config.rs b/crates/nuwiki-lsp/src/config.rs new file mode 100644 index 0000000..2789bcd --- /dev/null +++ b/crates/nuwiki-lsp/src/config.rs @@ -0,0 +1,216 @@ +//! Server-side runtime config. +//! +//! Populated from `InitializeParams.initialization_options` at boot and +//! refreshed via `workspace/didChangeConfiguration` (P18). v1.0 single-wiki +//! shapes are accepted and desugared into the v1.1 `wikis = [...]` form so +//! existing setups keep working (P16). +//! +//! Only the fields actually consumed in Phase 11 land here. Phases 13–19 +//! extend `WikiConfig` (diary path, html path, template options, …) and +//! the top-level `Config` (diagnostic severity, mappings opt-in, …) as +//! they need them. + +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use tower_lsp::lsp_types::{InitializeParams, Url}; + +#[derive(Debug, Clone, Default)] +pub struct Config { + pub wikis: Vec, + pub diagnostic: DiagnosticConfig, +} + +#[derive(Debug, Clone)] +pub struct WikiConfig { + pub name: String, + pub root: PathBuf, + pub file_extension: String, + pub syntax: String, +} + +#[derive(Debug, Clone, Default)] +pub struct DiagnosticConfig { + pub link_severity: LinkSeverity, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum LinkSeverity { + Off, + Hint, + #[default] + Warning, + Error, +} + +impl WikiConfig { + /// Synthetic default used when the client supplies neither + /// `initializationOptions` nor a workspace root. `name` is empty; + /// callers shouldn't depend on it for routing. + pub fn empty() -> Self { + Self { + name: String::new(), + root: PathBuf::new(), + file_extension: ".wiki".into(), + syntax: "vimwiki".into(), + } + } + + pub fn from_root(root: PathBuf) -> Self { + Self { + name: root + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(), + root, + file_extension: ".wiki".into(), + syntax: "vimwiki".into(), + } + } +} + +impl Config { + /// Build a `Config` from `InitializeParams`. + /// + /// Priority for the wikis list: + /// 1. `initialization_options.wikis = [...]` (v1.1 shape) + /// 2. `initialization_options.wiki_root + file_extension + syntax` + /// (v1.0 single-wiki shape, P16) + /// 3. `workspace_folders[0]` if present + /// 4. deprecated `root_uri` if present + /// 5. empty list (server stays alive but won't index anything) + pub fn from_init_params(params: &InitializeParams) -> Self { + let raw = params + .initialization_options + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .unwrap_or_default(); + + let wikis = if let Some(list) = raw.wikis { + list.into_iter().map(WikiConfig::from).collect() + } else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) { + vec![WikiConfig { + name: root + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(), + root, + file_extension: raw.file_extension.unwrap_or_else(|| ".wiki".into()), + syntax: raw.syntax.unwrap_or_else(|| "vimwiki".into()), + }] + } else if let Some(folder) = first_workspace_folder(params) { + vec![WikiConfig::from_root(folder)] + } else { + Vec::new() + }; + + Config { + wikis, + diagnostic: DiagnosticConfig::default(), + } + } + + /// Re-apply settings received via `workspace/didChangeConfiguration`. + /// On parse failure the existing config is returned unchanged. + pub fn apply_change(&mut self, value: &serde_json::Value) { + let raw: InitOptions = match serde_json::from_value(value.clone()) { + Ok(r) => r, + Err(_) => return, + }; + if let Some(list) = raw.wikis { + self.wikis = list.into_iter().map(WikiConfig::from).collect(); + } else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) { + let mut wc = WikiConfig::from_root(root); + if let Some(ext) = raw.file_extension { + wc.file_extension = ext; + } + if let Some(s) = raw.syntax { + wc.syntax = s; + } + self.wikis = vec![wc]; + } + } +} + +// ===== Wire schema ===== + +#[derive(Debug, Default, Deserialize)] +#[serde(default, rename_all = "snake_case")] +struct InitOptions { + wikis: Option>, + // v1.0 single-wiki compat fields + wiki_root: Option, + file_extension: Option, + syntax: Option, + log_level: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct RawWiki { + #[serde(default)] + name: Option, + root: String, + #[serde(default)] + file_extension: Option, + #[serde(default)] + syntax: Option, +} + +impl From for WikiConfig { + fn from(r: RawWiki) -> Self { + let root = expand_path(&r.root).unwrap_or_default(); + let derived_name = root + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + WikiConfig { + name: r.name.unwrap_or(derived_name), + root, + file_extension: r.file_extension.unwrap_or_else(|| ".wiki".into()), + syntax: r.syntax.unwrap_or_else(|| "vimwiki".into()), + } + } +} + +fn expand_path(s: &str) -> Option { + let trimmed = s.trim(); + if trimmed.is_empty() { + return None; + } + // Best-effort `~` expansion. Avoids pulling in `dirs` or `home`. + if let Some(rest) = trimmed.strip_prefix("~/") { + if let Some(home) = std::env::var_os("HOME") { + return Some(Path::new(&home).join(rest)); + } + } else if trimmed == "~" { + if let Some(home) = std::env::var_os("HOME") { + return Some(PathBuf::from(home)); + } + } + Some(PathBuf::from(trimmed)) +} + +fn first_workspace_folder(params: &InitializeParams) -> Option { + if let Some(folders) = ¶ms.workspace_folders { + if let Some(first) = folders.first() { + return first.uri.to_file_path().ok(); + } + } + #[allow(deprecated)] + if let Some(uri) = ¶ms.root_uri { + return uri.to_file_path().ok(); + } + None +} + +/// Test helper: build a `Config` from a JSON value (mimics what arrives +/// in `initializationOptions`). +pub fn config_from_json(value: serde_json::Value) -> Config { + let mut cfg = Config::default(); + cfg.apply_change(&value); + cfg +} + +#[allow(dead_code)] +fn _ensure_url_is_used(_u: &Url) {} diff --git a/crates/nuwiki-lsp/src/edits.rs b/crates/nuwiki-lsp/src/edits.rs new file mode 100644 index 0000000..7aad9ba --- /dev/null +++ b/crates/nuwiki-lsp/src/edits.rs @@ -0,0 +1,159 @@ +//! Helpers for building `WorkspaceEdit`s. +//! +//! Phase 13+ (rename, list/table edits, link/TOC generation) all return +//! `WorkspaceEdit`. The builder centralises the byte-offset → LSP-position +//! conversion (UTF-8 or UTF-16, matching whatever was negotiated in +//! `initialize`) so each call site doesn't have to redo it. +//! +//! The builder emits `documentChanges` (with `Operation` for file ops) +//! whenever any file op is queued, and falls back to the simpler +//! `changes` field for pure text-edit batches — the latter is what older +//! LSP 3.15- clients understand. + +use std::collections::HashMap; + +use tower_lsp::lsp_types::{ + CreateFile, DeleteFile, DocumentChangeOperation, DocumentChanges, OneOf, + OptionalVersionedTextDocumentIdentifier, Position, Range, RenameFile, ResourceOp, + TextDocumentEdit, TextEdit, Url, WorkspaceEdit, +}; + +use nuwiki_core::ast::Span; + +use crate::semantic_tokens::LineIndex; + +/// Replace the source covered by `span` with `replacement`. `text` and +/// `utf8` are needed to translate the byte-offset span into the LSP +/// `Range` the client expects. +pub fn text_edit_replace(span: Span, replacement: String, text: &str, utf8: bool) -> TextEdit { + TextEdit { + range: span_to_range(span, text, utf8), + new_text: replacement, + } +} + +/// Insert `content` at `pos`. The caller has already converted to LSP +/// coordinates — no further conversion is performed here. +pub fn text_edit_insert(pos: Position, content: String) -> TextEdit { + TextEdit { + range: Range { + start: pos, + end: pos, + }, + new_text: content, + } +} + +/// Delete the source covered by `span`. +pub fn text_edit_delete(span: Span, text: &str, utf8: bool) -> TextEdit { + text_edit_replace(span, String::new(), text, utf8) +} + +pub fn op_rename(old: Url, new: Url) -> DocumentChangeOperation { + DocumentChangeOperation::Op(ResourceOp::Rename(RenameFile { + old_uri: old, + new_uri: new, + options: None, + annotation_id: None, + })) +} + +pub fn op_delete(uri: Url) -> DocumentChangeOperation { + // lsp-types 0.94.x ships `DeleteFile` without `annotation_id`; later + // versions added it. Stick with the 0.94 shape until we move tower-lsp. + DocumentChangeOperation::Op(ResourceOp::Delete(DeleteFile { uri, options: None })) +} + +pub fn op_create(uri: Url) -> DocumentChangeOperation { + DocumentChangeOperation::Op(ResourceOp::Create(CreateFile { + uri, + options: None, + annotation_id: None, + })) +} + +#[derive(Default)] +pub struct WorkspaceEditBuilder { + changes: HashMap>, + ops: Vec, +} + +impl WorkspaceEditBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn edit(&mut self, uri: Url, edit: TextEdit) -> &mut Self { + self.changes.entry(uri).or_default().push(edit); + self + } + + pub fn edits(&mut self, uri: Url, edits: I) -> &mut Self + where + I: IntoIterator, + { + self.changes.entry(uri).or_default().extend(edits); + self + } + + pub fn file_op(&mut self, op: DocumentChangeOperation) -> &mut Self { + self.ops.push(op); + self + } + + pub fn is_empty(&self) -> bool { + self.changes.is_empty() && self.ops.is_empty() + } + + pub fn build(self) -> WorkspaceEdit { + // documentChanges is required whenever there are file ops (LSP + // doesn't represent renames/deletes/creates in the flat `changes` + // map). For pure text edits the older `changes` form keeps + // compatibility with LSP 3.15-clients. + if self.ops.is_empty() { + return WorkspaceEdit { + changes: Some(self.changes), + document_changes: None, + change_annotations: None, + }; + } + + let mut document_changes: Vec = self.ops; + // Edits land after file ops so the file exists when the text edit + // applies (e.g. create + initial content). + for (uri, edits) in self.changes { + document_changes.push(DocumentChangeOperation::Edit(TextDocumentEdit { + text_document: OptionalVersionedTextDocumentIdentifier { uri, version: None }, + edits: edits.into_iter().map(OneOf::Left).collect(), + })); + } + WorkspaceEdit { + changes: None, + document_changes: Some(DocumentChanges::Operations(document_changes)), + change_annotations: None, + } + } +} + +fn span_to_range(span: Span, text: &str, utf8: bool) -> Range { + let idx = LineIndex::new(text); + Range { + start: Position { + line: span.start.line, + character: char_at(span.start.line, span.start.column, text, &idx, utf8), + }, + end: Position { + line: span.end.line, + character: char_at(span.end.line, span.end.column, text, &idx, utf8), + }, + } +} + +fn char_at(line: u32, byte_col: u32, text: &str, idx: &LineIndex, utf8: bool) -> u32 { + if utf8 { + return byte_col; + } + let line_str = idx.line_str(line, text); + let upto = (byte_col as usize).min(line_str.len()); + line_str[..upto].chars().map(char::len_utf16).sum::() as u32 +} diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index e566f46..def5d54 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -21,9 +21,12 @@ //! The workspace scan runs in a background tokio task spawned from //! `initialize` (P8) with `window/workDoneProgress` begin/end events. +pub mod config; +pub mod edits; pub mod index; pub mod nav; pub mod semantic_tokens; +pub mod wiki; use std::io; use std::path::PathBuf; @@ -35,11 +38,11 @@ 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, - GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents, HoverParams, - HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams, Location, - MarkupContent, MarkupKind, MessageType, OneOf, PositionEncodingKind, ProgressParams, + Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams, + DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentSymbol, DocumentSymbolParams, + DocumentSymbolResponse, 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, @@ -57,7 +60,20 @@ use nuwiki_core::ast::{ 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. @@ -96,8 +112,12 @@ 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>, + /// Per-wiki state (P11). Vector holds one entry until Phase 18 + /// (multi-wiki) lets users add more. + wikis: Arc>>, + /// Server config received via `initializationOptions` and refreshed + /// via `workspace/didChangeConfiguration` (P18). + config: Arc>, } impl Backend { @@ -109,10 +129,68 @@ 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())), + 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() + } + + /// Scaffolded for Phase 12+ commands that operate on the default wiki + /// without a URI in hand (workspace-level operations). + #[allow(dead_code)] + fn default_wiki(&self) -> Option { + let wikis = self.wikis.read().ok()?; + wikis.first().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)) + } + + /// 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. + /// + /// Scaffolded ahead of the Phase 13 caller — `#[allow(dead_code)]` + /// drops once Phase 13 wires it in. + #[allow(dead_code)] + 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?; + 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. @@ -126,11 +204,34 @@ impl Backend { } }; let ast = plugin.parse(&text); - let diagnostics = ast_diagnostics(&ast, &text, self.use_utf8.load(Ordering::Relaxed)); + let utf8 = 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"); + // 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(&idx_guard), + page_name.as_deref(), + &cfg.diagnostic, + utf8, + ) + } else { + collect_diagnostics(&ast, &text, 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); } @@ -161,11 +262,11 @@ 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()); - } + // 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 { @@ -214,18 +315,31 @@ impl LanguageServer for Backend { ) .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); + // 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(¶ms.settings); + } + async fn shutdown(&self) -> LspResult<()> { Ok(()) } @@ -318,22 +432,25 @@ impl LanguageServer for Backend { }; 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(); + 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| heading_range_in(&idx, &target, a, utf8)) + .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, @@ -360,14 +477,9 @@ impl LanguageServer for Backend { // 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 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(), @@ -379,7 +491,10 @@ impl LanguageServer for Backend { let Some(page) = target_page else { return Ok(None); }; - let idx = self.index.read().expect("index lock poisoned"); + 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() @@ -420,22 +535,21 @@ impl LanguageServer for Backend { 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(), - }, + 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) } @@ -465,7 +579,10 @@ impl LanguageServer for Backend { if !is_inside_wikilink(&doc.text, line, col) { return Ok(None); } - let idx = self.index.read().expect("index lock poisoned"); + 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() @@ -484,23 +601,26 @@ impl LanguageServer for Backend { params: WorkspaceSymbolParams, ) -> LspResult>> { let q = params.query.to_lowercase(); - let idx = self.index.read().expect("index lock poisoned"); + let wikis = self.wikis.read().expect("wikis lock poisoned").clone(); 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()), - }); + 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()), + }); + } } } } @@ -579,9 +699,34 @@ fn utf16_column(text: &str, line: u32, byte_col: u32) -> u32 { .sum::() 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 { + collect_diagnostics(ast, text, None, None, &DiagnosticConfig::default(), utf8) +} + +/// Composable diagnostic collector (P11 §12.2.4). +/// +/// Sources, each gated by `cfg`: +/// +/// 1. Parse errors (always on). +/// 2. Broken-link warnings — wired up in Phase 15. For Phase 11 the +/// `index` / `page_name` / `cfg.link_severity` arguments are +/// accepted but the source is a stub returning no diagnostics, so +/// handlers can already pass them in without further refactor. +pub fn collect_diagnostics( + ast: &DocumentNode, + text: &str, + 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); + // Phase 15 will implement link-health here. + let _ = (index, page_name, cfg); out } @@ -732,23 +877,10 @@ fn keyword_str(k: nuwiki_core::ast::Keyword) -> &'static str { } // ===== 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 -} +// +// `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 diff --git a/crates/nuwiki-lsp/src/wiki.rs b/crates/nuwiki-lsp/src/wiki.rs new file mode 100644 index 0000000..c136b15 --- /dev/null +++ b/crates/nuwiki-lsp/src/wiki.rs @@ -0,0 +1,70 @@ +//! `Wiki` aggregate — per-wiki state, even when there's only one. +//! +//! v1.0 shipped with a single `Arc>` on `Backend`. +//! Phase 11 lifts that into a `Wiki` aggregate so Phase 18 (multi-wiki) +//! only changes config shape, not data flow. Phases 12–17 always operate +//! on a `Wiki`; in practice the `wikis` `Vec` has one entry until 18. + +use std::path::Path; +use std::sync::{Arc, RwLock}; + +use tower_lsp::lsp_types::Url; + +use crate::config::WikiConfig; +use crate::index::WorkspaceIndex; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Default)] +pub struct WikiId(pub u32); + +#[derive(Clone)] +pub struct Wiki { + pub id: WikiId, + pub config: WikiConfig, + pub index: Arc>, +} + +impl Wiki { + pub fn new(id: WikiId, config: WikiConfig) -> Self { + let index = Arc::new(RwLock::new(WorkspaceIndex::new(Some(config.root.clone())))); + Self { id, config, index } + } + + /// True when `uri` resolves to a file under this wiki's root. + pub fn contains(&self, uri: &Url) -> bool { + let Ok(path) = uri.to_file_path() else { + return false; + }; + path.starts_with(&self.config.root) + } + + /// Length of the wiki's root path (component count) — used to break + /// ties in nested-root layouts. + pub fn root_depth(&self) -> usize { + self.config.root.components().count() + } + + pub fn root(&self) -> &Path { + &self.config.root + } +} + +/// Pick the wiki whose root is the longest prefix of `uri`. Returns the +/// wiki id, not a guard, so callers can release the read lock before +/// awaiting. +pub fn resolve_uri_to_wiki(wikis: &[Wiki], uri: &Url) -> Option { + wikis + .iter() + .filter(|w| w.contains(uri)) + .max_by_key(|w| w.root_depth()) + .map(|w| w.id) +} + +/// Build the initial `Vec` from a list of `WikiConfig`s. Always +/// assigns ids by position so `WikiId(0)` is the default wiki. +pub fn build_wikis(configs: &[WikiConfig]) -> Vec { + configs + .iter() + .enumerate() + .map(|(i, cfg)| Wiki::new(WikiId(i as u32), cfg.clone())) + .collect() +} diff --git a/crates/nuwiki-lsp/tests/phase11_plumbing.rs b/crates/nuwiki-lsp/tests/phase11_plumbing.rs new file mode 100644 index 0000000..9b1ef3f --- /dev/null +++ b/crates/nuwiki-lsp/tests/phase11_plumbing.rs @@ -0,0 +1,260 @@ +//! Phase 11 plumbing tests: WorkspaceEditBuilder, Config desugar, Wiki +//! resolution, collect_diagnostics composition. The async closed-doc +//! loader (`Backend::read_or_open`) is exercised via integration of the +//! pure pieces — the full handler path is exercised once Phase 13 wires +//! it into `workspace/rename`. + +use std::path::PathBuf; + +use nuwiki_core::ast::{ + inline::InlineNode, BlockNode, DocumentNode, ErrorNode, ParagraphNode, Position as AstPosition, + Span, TextNode, +}; +use nuwiki_lsp::collect_diagnostics; +use nuwiki_lsp::config::{config_from_json, Config, DiagnosticConfig, LinkSeverity, WikiConfig}; +use nuwiki_lsp::edits::{ + op_create, op_delete, op_rename, text_edit_delete, text_edit_insert, text_edit_replace, + WorkspaceEditBuilder, +}; +use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki, Wiki, WikiId}; +use serde_json::json; +use tower_lsp::lsp_types::{DocumentChanges, Position as LspPosition, Url}; + +// ===== Edits ===== + +fn span_one_line(byte_col: u32, len: u32) -> Span { + Span::new( + AstPosition { + line: 0, + column: byte_col, + offset: byte_col as usize, + }, + AstPosition { + line: 0, + column: byte_col + len, + offset: (byte_col + len) as usize, + }, + ) +} + +#[test] +fn text_edit_replace_passthrough_in_utf8() { + let edit = text_edit_replace(span_one_line(0, 5), "world".into(), "hello there", true); + assert_eq!(edit.range.start.character, 0); + assert_eq!(edit.range.end.character, 5); + assert_eq!(edit.new_text, "world"); +} + +#[test] +fn text_edit_replace_converts_to_utf16_for_multibyte() { + // "héllo" — h(1B) é(2B) l(1B) l(1B) o(1B); UTF-16 chars: 1+1+1+1+1=5. + // span covers bytes 0..3 (h + é); UTF-16 character count = 2. + let edit = text_edit_replace(span_one_line(0, 3), "X".into(), "héllo", false); + assert_eq!(edit.range.start.character, 0); + assert_eq!(edit.range.end.character, 2); +} + +#[test] +fn text_edit_insert_produces_zero_width_range() { + let edit = text_edit_insert( + LspPosition { + line: 0, + character: 4, + }, + "X".into(), + ); + assert_eq!(edit.range.start, edit.range.end); + assert_eq!(edit.new_text, "X"); +} + +#[test] +fn text_edit_delete_has_empty_new_text() { + let edit = text_edit_delete(span_one_line(0, 3), "abcdef", true); + assert_eq!(edit.new_text, ""); + assert_eq!(edit.range.end.character, 3); +} + +#[test] +fn builder_with_only_edits_uses_changes_map() { + let uri = Url::parse("file:///tmp/a.wiki").unwrap(); + let mut b = WorkspaceEditBuilder::new(); + b.edit( + uri.clone(), + text_edit_replace(span_one_line(0, 3), "new".into(), "old text", true), + ); + let we = b.build(); + assert!(we.changes.is_some()); + assert!(we.document_changes.is_none()); + let changes = we.changes.unwrap(); + assert_eq!(changes.len(), 1); + assert_eq!(changes[&uri].len(), 1); +} + +#[test] +fn builder_with_file_op_promotes_to_document_changes() { + let old = Url::parse("file:///tmp/A.wiki").unwrap(); + let new = Url::parse("file:///tmp/B.wiki").unwrap(); + let mut b = WorkspaceEditBuilder::new(); + b.file_op(op_rename(old.clone(), new.clone())); + b.edit( + new.clone(), + text_edit_replace(span_one_line(0, 1), "X".into(), "Y", true), + ); + let we = b.build(); + assert!(we.changes.is_none()); + let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else { + panic!("expected Operations variant"); + }; + // Rename comes first (file ops always precede content edits in the + // builder), then the text edit on the renamed file. + assert_eq!(ops.len(), 2); +} + +#[test] +fn builder_is_empty_until_something_pushed() { + let mut b = WorkspaceEditBuilder::new(); + assert!(b.is_empty()); + b.file_op(op_delete(Url::parse("file:///x").unwrap())); + assert!(!b.is_empty()); +} + +#[test] +fn op_create_round_trips_through_builder() { + let uri = Url::parse("file:///tmp/new.wiki").unwrap(); + let mut b = WorkspaceEditBuilder::new(); + b.file_op(op_create(uri)); + let we = b.build(); + assert!(we.document_changes.is_some()); +} + +// ===== Config ===== + +#[test] +fn config_default_is_empty() { + let c = Config::default(); + assert!(c.wikis.is_empty()); + assert_eq!(c.diagnostic.link_severity, LinkSeverity::Warning); +} + +#[test] +fn v1_0_single_wiki_desugars_to_one_entry() { + let cfg = config_from_json(json!({ + "wiki_root": "/tmp/vimwiki", + "file_extension": ".wiki", + "syntax": "vimwiki", + })); + assert_eq!(cfg.wikis.len(), 1); + assert_eq!(cfg.wikis[0].root, PathBuf::from("/tmp/vimwiki")); + assert_eq!(cfg.wikis[0].file_extension, ".wiki"); + assert_eq!(cfg.wikis[0].syntax, "vimwiki"); +} + +#[test] +fn explicit_wikis_list_overrides_legacy_shape() { + let cfg = config_from_json(json!({ + "wiki_root": "/tmp/IGNORED", + "wikis": [ + { "root": "/tmp/personal", "name": "personal" }, + { "root": "/tmp/work", "syntax": "vimwiki" }, + ], + })); + assert_eq!(cfg.wikis.len(), 2); + assert_eq!(cfg.wikis[0].name, "personal"); + assert_eq!(cfg.wikis[1].root, PathBuf::from("/tmp/work")); +} + +#[test] +fn empty_json_yields_no_wikis() { + let cfg = config_from_json(json!({})); + assert!(cfg.wikis.is_empty()); +} + +#[test] +fn invalid_json_leaves_existing_config_unchanged() { + let mut cfg = Config::default(); + cfg.wikis + .push(WikiConfig::from_root(PathBuf::from("/tmp/a"))); + cfg.apply_change(&json!("not a config object")); + // Existing wikis still present. + assert_eq!(cfg.wikis.len(), 1); +} + +// ===== Wiki aggregate ===== + +fn wiki_at(id: u32, root: &str) -> Wiki { + Wiki::new(WikiId(id), WikiConfig::from_root(PathBuf::from(root))) +} + +#[test] +fn build_wikis_assigns_sequential_ids() { + let configs = vec![ + WikiConfig::from_root(PathBuf::from("/tmp/a")), + WikiConfig::from_root(PathBuf::from("/tmp/b")), + ]; + let wikis = build_wikis(&configs); + assert_eq!(wikis.len(), 2); + assert_eq!(wikis[0].id, WikiId(0)); + assert_eq!(wikis[1].id, WikiId(1)); +} + +#[test] +fn resolve_uri_to_wiki_picks_longest_prefix() { + let wikis = vec![wiki_at(0, "/wiki"), wiki_at(1, "/wiki/sub")]; + let uri = Url::from_file_path("/wiki/sub/page.wiki").unwrap(); + assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1))); +} + +#[test] +fn resolve_uri_to_wiki_misses_outside_any_root() { + let wikis = vec![wiki_at(0, "/wiki")]; + let uri = Url::from_file_path("/other/page.wiki").unwrap(); + assert!(resolve_uri_to_wiki(&wikis, &uri).is_none()); +} + +#[test] +fn wiki_contains_respects_root_prefix() { + let w = wiki_at(0, "/wiki"); + assert!(w.contains(&Url::from_file_path("/wiki/note.wiki").unwrap())); + assert!(!w.contains(&Url::from_file_path("/other/note.wiki").unwrap())); +} + +// ===== Diagnostics composition ===== + +#[test] +fn collect_diagnostics_emits_one_per_error_node() { + let doc = DocumentNode { + span: Span::default(), + metadata: Default::default(), + children: vec![ + BlockNode::Error(ErrorNode { + span: span_one_line(0, 3), + raw: "oops".into(), + message: "broken".into(), + }), + BlockNode::Paragraph(ParagraphNode { + span: Span::default(), + children: vec![InlineNode::Text(TextNode { + span: Span::default(), + content: "ok".into(), + })], + }), + ], + }; + let cfg = DiagnosticConfig::default(); + let diags = collect_diagnostics(&doc, "abc ok", None, None, &cfg, true); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].message, "broken"); +} + +#[test] +fn collect_diagnostics_link_health_stubs_to_empty_in_phase_11() { + // With an index supplied but the link-health source not yet wired in, + // we should still get zero link-related diagnostics (Phase 15 wires + // them in). + let doc = DocumentNode::default(); + let cfg = DiagnosticConfig { + link_severity: LinkSeverity::Error, + }; + let diags = collect_diagnostics(&doc, "", None, Some("Home"), &cfg, true); + assert!(diags.is_empty()); +}