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:
@@ -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