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
+216
View File
@@ -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 1319
//! 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<WikiConfig>,
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::<InitOptions>(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<Vec<RawWiki>>,
// v1.0 single-wiki compat fields
wiki_root: Option<String>,
file_extension: Option<String>,
syntax: Option<String>,
log_level: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct RawWiki {
#[serde(default)]
name: Option<String>,
root: String,
#[serde(default)]
file_extension: Option<String>,
#[serde(default)]
syntax: Option<String>,
}
impl From<RawWiki> 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<PathBuf> {
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<PathBuf> {
if let Some(folders) = &params.workspace_folders {
if let Some(first) = folders.first() {
return first.uri.to_file_path().ok();
}
}
#[allow(deprecated)]
if let Some(uri) = &params.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) {}
+159
View File
@@ -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<Url, Vec<TextEdit>>,
ops: Vec<DocumentChangeOperation>,
}
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<I>(&mut self, uri: Url, edits: I) -> &mut Self
where
I: IntoIterator<Item = TextEdit>,
{
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<DocumentChangeOperation> = 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::<usize>() as u32
}
+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
+70
View File
@@ -0,0 +1,70 @@
//! `Wiki` aggregate — per-wiki state, even when there's only one.
//!
//! v1.0 shipped with a single `Arc<RwLock<WorkspaceIndex>>` on `Backend`.
//! Phase 11 lifts that into a `Wiki` aggregate so Phase 18 (multi-wiki)
//! only changes config shape, not data flow. Phases 1217 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<RwLock<WorkspaceIndex>>,
}
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<WikiId> {
wikis
.iter()
.filter(|w| w.contains(uri))
.max_by_key(|w| w.root_depth())
.map(|w| w.id)
}
/// Build the initial `Vec<Wiki>` 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<Wiki> {
configs
.iter()
.enumerate()
.map(|(i, cfg)| Wiki::new(WikiId(i as u32), cfg.clone()))
.collect()
}