phase 11: plumbing prereqs — Wiki, edits, config, snapshot, diags
CI / cargo fmt --check (push) Successful in 25s
CI / cargo clippy (push) Successful in 1m16s
CI / cargo test (push) Successful in 1m15s

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<RwLock<Index>>).
  `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<RwLock<WorkspaceIndex>>` replaced by
  `wikis: Arc<RwLock<Vec<Wiki>>>` + `config: Arc<RwLock<Config>>`. 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 14:32:22 +00:00
parent cb244a8080
commit ea574f23f8
8 changed files with 954 additions and 96 deletions
+223 -91
View File
@@ -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<AtomicBool>,
/// Workspace index — populated lazily in the background per P8.
index: Arc<RwLock<WorkspaceIndex>>,
/// 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 {
@@ -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<Wiki> {
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<Wiki> {
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<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.
///
/// 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<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.
@@ -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(&params);
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(&params);
*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(&params.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<Location> = 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<CompletionItem> = idx
.page_names()
.into_iter()
@@ -484,23 +601,26 @@ impl LanguageServer for Backend {
params: WorkspaceSymbolParams,
) -> LspResult<Option<Vec<LspSymbolInformation>>> {
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::<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> {
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<Diagnostic> {
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<PathBuf> {
if let Some(folders) = &params.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) = &params.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