phase 11: plumbing prereqs — Wiki, edits, config, snapshot, diags
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:
Generated
+2
@@ -393,6 +393,8 @@ dependencies = [
|
||||
"async-trait",
|
||||
"dashmap 6.1.0",
|
||||
"nuwiki-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower-lsp",
|
||||
]
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
A Vim/Neovim plugin providing full vimwiki syntax support, implemented as a
|
||||
Rust-based language server (LSP).
|
||||
|
||||
> **Status:** All phases (0–10) complete. v1 feature set landed:
|
||||
> editor-independent core, vimwiki lexer + parser, HTML renderer,
|
||||
> tower-lsp server with diagnostics + outline + semantic tokens +
|
||||
> navigation + workspace index, plugin glue, and release pipeline.
|
||||
> **Status:** v1.0 (Phases 0–10) shipped — parsing, highlighting, navigation,
|
||||
> editor glue, and release pipeline are all live. v1.1 (Phases 11–19) in
|
||||
> progress, closing the gap to a full vimwiki replacement: tags, diary,
|
||||
> server-side edit commands, link health, HTML export commands, multi-wiki,
|
||||
> and the `:Vimwiki*` keymap layer.
|
||||
|
||||
See [`SPEC.md`](./SPEC.md) for the full project specification.
|
||||
|
||||
## Implementation status
|
||||
|
||||
### v1.0 — foundation
|
||||
|
||||
| Phase | Name | Status |
|
||||
|---|---|---|
|
||||
| 0 | Scaffolding | ✅ done |
|
||||
@@ -26,6 +29,20 @@ See [`SPEC.md`](./SPEC.md) for the full project specification.
|
||||
| 9 | Editor Glue | ✅ done |
|
||||
| 10 | CI/CD release pipeline | ✅ done |
|
||||
|
||||
### v1.1 — full vimwiki replacement
|
||||
|
||||
| Phase | Name | Status |
|
||||
|---|---|---|
|
||||
| 11 | Plumbing prerequisites | ✅ done |
|
||||
| 12 | Tags | ⏳ |
|
||||
| 13 | Workspace edits + executeCommand | ⏳ |
|
||||
| 14 | List & table edit commands | ⏳ |
|
||||
| 15 | Link health + TOC/index generation | ⏳ |
|
||||
| 16 | Diary | ⏳ |
|
||||
| 17 | HTML export commands | ⏳ |
|
||||
| 18 | Multi-wiki | ⏳ |
|
||||
| 19 | Editor glue v2 (`:Vimwiki*` compat, keymaps, text objects, folding) | ⏳ |
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
|
||||
@@ -15,6 +15,8 @@ workspace = true
|
||||
[dependencies]
|
||||
nuwiki-core = { path = "../nuwiki-core", version = "0.1.0" }
|
||||
tower-lsp = "0.20"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync", "fs"] }
|
||||
dashmap = "6"
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Server-side runtime config.
|
||||
//!
|
||||
//! Populated from `InitializeParams.initialization_options` at boot and
|
||||
//! refreshed via `workspace/didChangeConfiguration` (P18). v1.0 single-wiki
|
||||
//! shapes are accepted and desugared into the v1.1 `wikis = [...]` form so
|
||||
//! existing setups keep working (P16).
|
||||
//!
|
||||
//! Only the fields actually consumed in Phase 11 land here. Phases 13–19
|
||||
//! extend `WikiConfig` (diary path, html path, template options, …) and
|
||||
//! the top-level `Config` (diagnostic severity, mappings opt-in, …) as
|
||||
//! they need them.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
use tower_lsp::lsp_types::{InitializeParams, Url};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Config {
|
||||
pub wikis: Vec<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) = ¶ms.workspace_folders {
|
||||
if let Some(first) = folders.first() {
|
||||
return first.uri.to_file_path().ok();
|
||||
}
|
||||
}
|
||||
#[allow(deprecated)]
|
||||
if let Some(uri) = ¶ms.root_uri {
|
||||
return uri.to_file_path().ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Test helper: build a `Config` from a JSON value (mimics what arrives
|
||||
/// in `initializationOptions`).
|
||||
pub fn config_from_json(value: serde_json::Value) -> Config {
|
||||
let mut cfg = Config::default();
|
||||
cfg.apply_change(&value);
|
||||
cfg
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _ensure_url_is_used(_u: &Url) {}
|
||||
@@ -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
@@ -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(¶ms);
|
||||
if let Some(p) = &root {
|
||||
self.index.write().expect("index lock poisoned").root = Some(p.clone());
|
||||
}
|
||||
// Build server config from initializationOptions (P18) and the
|
||||
// workspace folders, then materialise the `Wiki` aggregate (P11).
|
||||
let cfg = Config::from_init_params(¶ms);
|
||||
*self.wikis.write().expect("wikis lock poisoned") = build_wikis(&cfg.wikis);
|
||||
*self.config.write().expect("config lock poisoned") = cfg;
|
||||
|
||||
Ok(InitializeResult {
|
||||
capabilities: ServerCapabilities {
|
||||
@@ -214,18 +315,31 @@ impl LanguageServer for Backend {
|
||||
)
|
||||
.await;
|
||||
|
||||
// Kick off the background workspace scan (P8). Skips if no root.
|
||||
let root = self.index.read().expect("index lock poisoned").root.clone();
|
||||
if let Some(root) = root {
|
||||
let index = Arc::clone(&self.index);
|
||||
// Kick off the background workspace scan (P8) — one task per wiki.
|
||||
let wikis = self.wikis.read().expect("wikis lock poisoned").clone();
|
||||
for wiki in wikis {
|
||||
if wiki.config.root.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let client = self.client.clone();
|
||||
let registry = Arc::clone(&self.registry);
|
||||
let index = Arc::clone(&wiki.index);
|
||||
let root = wiki.config.root.clone();
|
||||
tokio::spawn(async move {
|
||||
index_workspace(root, index, client, registry).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
|
||||
// Apply the new settings to `Config`. Re-indexing on root changes
|
||||
// is out of scope for Phase 11; for now we just accept the new
|
||||
// values so later commands see the right diagnostic settings etc.
|
||||
// Phase 18 (multi-wiki) revisits this with proper rebuild semantics.
|
||||
let mut cfg = self.config.write().expect("config lock poisoned");
|
||||
cfg.apply_change(¶ms.settings);
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> LspResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -318,22 +432,25 @@ impl LanguageServer for Backend {
|
||||
};
|
||||
let location = match node {
|
||||
InlineNode::WikiLink(w) => {
|
||||
let source_page = index::page_name_from_uri(
|
||||
uri,
|
||||
self.index
|
||||
.read()
|
||||
.expect("index lock poisoned")
|
||||
.root
|
||||
.as_deref(),
|
||||
);
|
||||
let idx = self.index.read().expect("index lock poisoned");
|
||||
let target_uri = idx.resolve(&w.target, &source_page).cloned();
|
||||
let wiki = self.wiki_for_uri(uri);
|
||||
let source_page =
|
||||
index::page_name_from_uri(uri, wiki.as_ref().map(|w| w.config.root.as_path()));
|
||||
let idx_guard = wiki
|
||||
.as_ref()
|
||||
.map(|w| w.index.read().expect("index lock poisoned"));
|
||||
let target_uri = idx_guard
|
||||
.as_ref()
|
||||
.and_then(|idx| idx.resolve(&w.target, &source_page).cloned());
|
||||
target_uri.map(|target| {
|
||||
let target_range = w
|
||||
.target
|
||||
.anchor
|
||||
.as_deref()
|
||||
.and_then(|a| heading_range_in(&idx, &target, a, utf8))
|
||||
.and_then(|a| {
|
||||
idx_guard
|
||||
.as_ref()
|
||||
.and_then(|idx| heading_range_in(idx, &target, a, utf8))
|
||||
})
|
||||
.unwrap_or_else(zero_range);
|
||||
Location {
|
||||
uri: target,
|
||||
@@ -360,14 +477,9 @@ impl LanguageServer for Backend {
|
||||
// about". Two cases:
|
||||
// - cursor is on a wikilink → references to the linked page
|
||||
// - cursor isn't on a link → references to the *current* page
|
||||
let source_page = index::page_name_from_uri(
|
||||
uri,
|
||||
self.index
|
||||
.read()
|
||||
.expect("index lock poisoned")
|
||||
.root
|
||||
.as_deref(),
|
||||
);
|
||||
let wiki = self.wiki_for_uri(uri);
|
||||
let source_page =
|
||||
index::page_name_from_uri(uri, wiki.as_ref().map(|w| w.config.root.as_path()));
|
||||
let target_page = match nav::find_inline_at(&doc.ast, line, col) {
|
||||
Some(InlineNode::WikiLink(w)) => match w.target.kind {
|
||||
nuwiki_core::ast::LinkKind::Wiki => w.target.path.clone(),
|
||||
@@ -379,7 +491,10 @@ impl LanguageServer for Backend {
|
||||
let Some(page) = target_page else {
|
||||
return Ok(None);
|
||||
};
|
||||
let idx = self.index.read().expect("index lock poisoned");
|
||||
let Some(wiki) = wiki else {
|
||||
return Ok(Some(Vec::new()));
|
||||
};
|
||||
let idx = wiki.index.read().expect("index lock poisoned");
|
||||
let locations: Vec<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 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();
|
||||
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()),
|
||||
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,8 +601,10 @@ 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 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) {
|
||||
@@ -504,6 +623,7 @@ impl LanguageServer for Backend {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
}
|
||||
@@ -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) = ¶ms.workspace_folders {
|
||||
if let Some(first) = folders.first() {
|
||||
if let Ok(p) = first.uri.to_file_path() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[allow(deprecated)]
|
||||
if let Some(uri) = ¶ms.root_uri {
|
||||
if let Ok(p) = uri.to_file_path() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
//
|
||||
// `workspace_root_from_params` lived here through Phase 10; Phase 11 lifted
|
||||
// that logic into `Config::from_init_params` (see `config.rs`) so workspace
|
||||
// folders and `root_uri` are handled alongside `initializationOptions`.
|
||||
|
||||
/// Background workspace scan (P8). Walks every `.wiki` file under `root`,
|
||||
/// parses it, and feeds it into the shared index. Wraps the work in a
|
||||
|
||||
@@ -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 12–17 always operate
|
||||
//! on a `Wiki`; in practice the `wikis` `Vec` has one entry until 18.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
use crate::config::WikiConfig;
|
||||
use crate::index::WorkspaceIndex;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Default)]
|
||||
pub struct WikiId(pub u32);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Wiki {
|
||||
pub id: WikiId,
|
||||
pub config: WikiConfig,
|
||||
pub index: Arc<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()
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user