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
Generated
+2
View File
@@ -393,6 +393,8 @@ dependencies = [
"async-trait", "async-trait",
"dashmap 6.1.0", "dashmap 6.1.0",
"nuwiki-core", "nuwiki-core",
"serde",
"serde_json",
"tokio", "tokio",
"tower-lsp", "tower-lsp",
] ]
+21 -4
View File
@@ -3,15 +3,18 @@
A Vim/Neovim plugin providing full vimwiki syntax support, implemented as a A Vim/Neovim plugin providing full vimwiki syntax support, implemented as a
Rust-based language server (LSP). Rust-based language server (LSP).
> **Status:** All phases (010) complete. v1 feature set landed: > **Status:** v1.0 (Phases 010) shipped — parsing, highlighting, navigation,
> editor-independent core, vimwiki lexer + parser, HTML renderer, > editor glue, and release pipeline are all live. v1.1 (Phases 1119) in
> tower-lsp server with diagnostics + outline + semantic tokens + > progress, closing the gap to a full vimwiki replacement: tags, diary,
> navigation + workspace index, plugin glue, and release pipeline. > 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. See [`SPEC.md`](./SPEC.md) for the full project specification.
## Implementation status ## Implementation status
### v1.0 — foundation
| Phase | Name | Status | | Phase | Name | Status |
|---|---|---| |---|---|---|
| 0 | Scaffolding | ✅ done | | 0 | Scaffolding | ✅ done |
@@ -26,6 +29,20 @@ See [`SPEC.md`](./SPEC.md) for the full project specification.
| 9 | Editor Glue | ✅ done | | 9 | Editor Glue | ✅ done |
| 10 | CI/CD release pipeline | ✅ 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 ## Repository layout
``` ```
+3 -1
View File
@@ -15,6 +15,8 @@ workspace = true
[dependencies] [dependencies]
nuwiki-core = { path = "../nuwiki-core", version = "0.1.0" } nuwiki-core = { path = "../nuwiki-core", version = "0.1.0" }
tower-lsp = "0.20" 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" dashmap = "6"
async-trait = "0.1" async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+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
}
+202 -70
View File
@@ -21,9 +21,12 @@
//! The workspace scan runs in a background tokio task spawned from //! The workspace scan runs in a background tokio task spawned from
//! `initialize` (P8) with `window/workDoneProgress` begin/end events. //! `initialize` (P8) with `window/workDoneProgress` begin/end events.
pub mod config;
pub mod edits;
pub mod index; pub mod index;
pub mod nav; pub mod nav;
pub mod semantic_tokens; pub mod semantic_tokens;
pub mod wiki;
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
@@ -35,11 +38,11 @@ use tokio::io::{AsyncRead, AsyncWrite};
use tower_lsp::jsonrpc::Result as LspResult; use tower_lsp::jsonrpc::Result as LspResult;
use tower_lsp::lsp_types::{ use tower_lsp::lsp_types::{
CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse, CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse,
Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams, Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams,
DidOpenTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentSymbol, DocumentSymbolParams,
GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents, HoverParams, DocumentSymbolResponse, GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents,
HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams, Location, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams,
MarkupContent, MarkupKind, MessageType, OneOf, PositionEncodingKind, ProgressParams, Location, MarkupContent, MarkupKind, MessageType, OneOf, PositionEncodingKind, ProgressParams,
ProgressParamsValue, ProgressToken, Range, ReferenceParams, SemanticTokens, ProgressParamsValue, ProgressToken, Range, ReferenceParams, SemanticTokens,
SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams, SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams,
SemanticTokensRangeParams, SemanticTokensRangeResult, SemanticTokensResult, SemanticTokensRangeParams, SemanticTokensRangeResult, SemanticTokensResult,
@@ -57,7 +60,20 @@ use nuwiki_core::ast::{
use nuwiki_core::syntax::vimwiki::VimwikiSyntax; use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxRegistry; use nuwiki_core::syntax::SyntaxRegistry;
use crate::config::{Config, DiagnosticConfig};
use crate::index::{IndexedPage, WorkspaceIndex}; 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 /// Run the LSP server over the given async reader/writer pair. The binary
/// (`nuwiki-ls`) just calls this with stdin/stdout. /// (`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. /// True after `initialize` if the client opted into UTF-8 encoding.
/// Otherwise we keep the LSP 3.16 default of UTF-16. /// Otherwise we keep the LSP 3.16 default of UTF-16.
use_utf8: Arc<AtomicBool>, use_utf8: Arc<AtomicBool>,
/// Workspace index — populated lazily in the background per P8. /// Per-wiki state (P11). Vector holds one entry until Phase 18
index: Arc<RwLock<WorkspaceIndex>>, /// (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 { impl Backend {
@@ -109,10 +129,68 @@ impl Backend {
documents: Arc::new(DashMap::new()), documents: Arc::new(DashMap::new()),
registry: Arc::new(registry), registry: Arc::new(registry),
use_utf8: Arc::new(AtomicBool::new(false)), 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) { async fn update_document(&self, uri: Url, text: String, version: i32) {
// For Phase 6 we always treat unknown buffers as vimwiki — if/when // For Phase 6 we always treat unknown buffers as vimwiki — if/when
// multi-syntax dispatch lands the pick should follow the URI's ext. // multi-syntax dispatch lands the pick should follow the URI's ext.
@@ -126,11 +204,34 @@ impl Backend {
} }
}; };
let ast = plugin.parse(&text); 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. // Compute diagnostics + update the matching wiki's index. All locks
{ // released before the `await` below.
let mut idx = self.index.write().expect("index lock poisoned"); 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); idx.upsert(uri.clone(), &ast);
} }
@@ -161,11 +262,11 @@ impl LanguageServer for Backend {
None // server defaults to UTF-16 per LSP 3.16. None // server defaults to UTF-16 per LSP 3.16.
}; };
// Capture the workspace root for the background indexer. // Build server config from initializationOptions (P18) and the
let root = workspace_root_from_params(&params); // workspace folders, then materialise the `Wiki` aggregate (P11).
if let Some(p) = &root { let cfg = Config::from_init_params(&params);
self.index.write().expect("index lock poisoned").root = Some(p.clone()); *self.wikis.write().expect("wikis lock poisoned") = build_wikis(&cfg.wikis);
} *self.config.write().expect("config lock poisoned") = cfg;
Ok(InitializeResult { Ok(InitializeResult {
capabilities: ServerCapabilities { capabilities: ServerCapabilities {
@@ -214,18 +315,31 @@ impl LanguageServer for Backend {
) )
.await; .await;
// Kick off the background workspace scan (P8). Skips if no root. // Kick off the background workspace scan (P8) — one task per wiki.
let root = self.index.read().expect("index lock poisoned").root.clone(); let wikis = self.wikis.read().expect("wikis lock poisoned").clone();
if let Some(root) = root { for wiki in wikis {
let index = Arc::clone(&self.index); if wiki.config.root.as_os_str().is_empty() {
continue;
}
let client = self.client.clone(); let client = self.client.clone();
let registry = Arc::clone(&self.registry); let registry = Arc::clone(&self.registry);
let index = Arc::clone(&wiki.index);
let root = wiki.config.root.clone();
tokio::spawn(async move { tokio::spawn(async move {
index_workspace(root, index, client, registry).await; 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<()> { async fn shutdown(&self) -> LspResult<()> {
Ok(()) Ok(())
} }
@@ -318,22 +432,25 @@ impl LanguageServer for Backend {
}; };
let location = match node { let location = match node {
InlineNode::WikiLink(w) => { InlineNode::WikiLink(w) => {
let source_page = index::page_name_from_uri( let wiki = self.wiki_for_uri(uri);
uri, let source_page =
self.index index::page_name_from_uri(uri, wiki.as_ref().map(|w| w.config.root.as_path()));
.read() let idx_guard = wiki
.expect("index lock poisoned") .as_ref()
.root .map(|w| w.index.read().expect("index lock poisoned"));
.as_deref(), let target_uri = idx_guard
); .as_ref()
let idx = self.index.read().expect("index lock poisoned"); .and_then(|idx| idx.resolve(&w.target, &source_page).cloned());
let target_uri = idx.resolve(&w.target, &source_page).cloned();
target_uri.map(|target| { target_uri.map(|target| {
let target_range = w let target_range = w
.target .target
.anchor .anchor
.as_deref() .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); .unwrap_or_else(zero_range);
Location { Location {
uri: target, uri: target,
@@ -360,14 +477,9 @@ impl LanguageServer for Backend {
// about". Two cases: // about". Two cases:
// - cursor is on a wikilink → references to the linked page // - cursor is on a wikilink → references to the linked page
// - cursor isn't on a link → references to the *current* page // - cursor isn't on a link → references to the *current* page
let source_page = index::page_name_from_uri( let wiki = self.wiki_for_uri(uri);
uri, let source_page =
self.index index::page_name_from_uri(uri, wiki.as_ref().map(|w| w.config.root.as_path()));
.read()
.expect("index lock poisoned")
.root
.as_deref(),
);
let target_page = match nav::find_inline_at(&doc.ast, line, col) { let target_page = match nav::find_inline_at(&doc.ast, line, col) {
Some(InlineNode::WikiLink(w)) => match w.target.kind { Some(InlineNode::WikiLink(w)) => match w.target.kind {
nuwiki_core::ast::LinkKind::Wiki => w.target.path.clone(), nuwiki_core::ast::LinkKind::Wiki => w.target.path.clone(),
@@ -379,7 +491,10 @@ impl LanguageServer for Backend {
let Some(page) = target_page else { let Some(page) = target_page else {
return Ok(None); 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 let locations: Vec<Location> = idx
.backlinks_for(&page) .backlinks_for(&page)
.iter() .iter()
@@ -420,22 +535,21 @@ impl LanguageServer for Backend {
let (markdown, hover_span) = match node { let (markdown, hover_span) = match node {
InlineNode::WikiLink(w) => { InlineNode::WikiLink(w) => {
let source_page = index::page_name_from_uri( let wiki = self.wiki_for_uri(uri);
uri, let source_page =
self.index index::page_name_from_uri(uri, wiki.as_ref().map(|w| w.config.root.as_path()));
.read() let body = if let Some(wiki) = wiki.as_ref() {
.expect("index lock poisoned") let idx = wiki.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 target_uri = idx.resolve(&w.target, &source_page).cloned();
let body = match target_uri.as_ref().and_then(|u| idx.page(u)) { match target_uri.as_ref().and_then(|u| idx.page(u)) {
Some(page) => preview_for(page, w.target.anchor.as_deref()), Some(page) => preview_for(page, w.target.anchor.as_deref()),
None => match &w.target.path { None => match &w.target.path {
Some(p) => format!("**{p}** — page not in index"), Some(p) => format!("**{p}** — page not in index"),
None => "(unresolved link)".into(), None => "(unresolved link)".into(),
}, },
}
} else {
"(no wiki configured)".into()
}; };
(body, w.span) (body, w.span)
} }
@@ -465,7 +579,10 @@ impl LanguageServer for Backend {
if !is_inside_wikilink(&doc.text, line, col) { if !is_inside_wikilink(&doc.text, line, col) {
return Ok(None); 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 let items: Vec<CompletionItem> = idx
.page_names() .page_names()
.into_iter() .into_iter()
@@ -484,8 +601,10 @@ impl LanguageServer for Backend {
params: WorkspaceSymbolParams, params: WorkspaceSymbolParams,
) -> LspResult<Option<Vec<LspSymbolInformation>>> { ) -> LspResult<Option<Vec<LspSymbolInformation>>> {
let q = params.query.to_lowercase(); 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(); let mut out = Vec::new();
for wiki in &wikis {
let idx = wiki.index.read().expect("index lock poisoned");
for page in idx.pages_by_uri.values() { for page in idx.pages_by_uri.values() {
for h in &page.headings { for h in &page.headings {
if q.is_empty() || h.title.to_lowercase().contains(&q) { if q.is_empty() || h.title.to_lowercase().contains(&q) {
@@ -504,6 +623,7 @@ impl LanguageServer for Backend {
} }
} }
} }
}
Ok(Some(out)) Ok(Some(out))
} }
} }
@@ -579,9 +699,34 @@ fn utf16_column(text: &str, line: u32, byte_col: u32) -> u32 {
.sum::<usize>() as 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> { 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(); let mut out = Vec::new();
walk_blocks_for_errors(&ast.children, text, utf8, &mut out); walk_blocks_for_errors(&ast.children, text, utf8, &mut out);
// Phase 15 will implement link-health here.
let _ = (index, page_name, cfg);
out out
} }
@@ -732,23 +877,10 @@ fn keyword_str(k: nuwiki_core::ast::Keyword) -> &'static str {
} }
// ===== Workspace + navigation helpers ===== // ===== Workspace + navigation helpers =====
//
fn workspace_root_from_params(params: &InitializeParams) -> Option<PathBuf> { // `workspace_root_from_params` lived here through Phase 10; Phase 11 lifted
if let Some(folders) = &params.workspace_folders { // that logic into `Config::from_init_params` (see `config.rs`) so workspace
if let Some(first) = folders.first() { // folders and `root_uri` are handled alongside `initializationOptions`.
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
}
/// Background workspace scan (P8). Walks every `.wiki` file under `root`, /// Background workspace scan (P8). Walks every `.wiki` file under `root`,
/// parses it, and feeds it into the shared index. Wraps the work in a /// 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()
}
+260
View File
@@ -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());
}