//! LSP-side workspace plumbing: //! - `WorkspaceEditBuilder` (changes-map vs document-changes promotion) //! and `TextEdit` UTF-8/UTF-16 conversion. //! - `Config` desugar from `initializationOptions` JSON (per-wiki keys, //! legacy single-wiki shape). //! - `Wiki` resolution: building wikis from raw config and matching //! URIs to the longest-prefix wiki root. //! - `collect_diagnostics` composition (parser errors + link-health). 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, None, &cfg, true); assert_eq!(diags.len(), 1); assert_eq!(diags[0].message, "broken"); } #[test] fn collect_diagnostics_link_health_emits_nothing_without_index() { // No index → link-health source is skipped, even when severity is on. let doc = DocumentNode::default(); let cfg = DiagnosticConfig { link_severity: LinkSeverity::Error, }; let diags = collect_diagnostics(&doc, "", None, None, Some("Home"), &cfg, true); assert!(diags.is_empty()); } // ===== Per-wiki config defaults + JSON parsing ===== #[test] fn defaults_carry_vimwiki_per_wiki_keys() { let cfg = WikiConfig::empty(); assert_eq!(cfg.index, "index"); assert_eq!(cfg.diary_frequency, "daily"); assert_eq!(cfg.diary_caption_level, 0); // upstream default assert_eq!(cfg.diary_sort, "desc"); assert_eq!(cfg.diary_header, "Diary"); assert_eq!(cfg.listsyms, " .oOX"); assert!(cfg.listsyms_propagate); assert_eq!(cfg.list_margin, -1); assert_eq!(cfg.links_space_char, " "); assert!(!cfg.auto_toc); assert!(!cfg.auto_generate_links); assert!(!cfg.auto_generate_tags); assert!(!cfg.auto_diary_index); // Generated-section captions match upstream defaults. assert_eq!(cfg.toc_header, "Contents"); assert_eq!(cfg.toc_header_level, 1); assert_eq!(cfg.links_header, "Generated Links"); assert_eq!(cfg.tags_header, "Generated Tags"); assert_eq!(cfg.listsym_rejected, "-"); // No external converter / public base URL by default. assert_eq!(cfg.html.custom_wiki2html, ""); assert_eq!(cfg.html.custom_wiki2html_args, ""); assert_eq!(cfg.html.base_url, ""); } #[test] fn raw_wiki_parses_every_new_key() { let cfg = config_from_json(serde_json::json!({ "wikis": [{ "root": "/tmp/w", "index": "home", "diary_frequency": "weekly", "diary_weekly_style": "date", "diary_start_week_day": "sunday", "diary_caption_level": 2, "diary_sort": "asc", "diary_header": "Journal", "listsyms": "abcde", "listsym_rejected": "✗", "listsyms_propagate": false, "list_margin": 2, "links_space_char": "_", "auto_toc": true, "auto_generate_links": true, "auto_generate_tags": 1, "auto_diary_index": true, "toc_header": "Table of Contents", "toc_header_level": 2, "links_header": "All Pages", "tags_header": "Tag Index", "tags_header_level": 3, "custom_wiki2html": "~/bin/my_wiki2html.sh", "custom_wiki2html_args": "--flag", "base_url": "https://example.com/wiki/", }], })); let w = &cfg.wikis[0]; assert_eq!(w.index, "home"); assert_eq!(w.diary_frequency, "weekly"); assert_eq!(w.diary_weekly_style, "date"); assert_eq!(w.diary_start_week_day, "sunday"); assert_eq!(w.diary_caption_level, 2); assert_eq!(w.diary_sort, "asc"); assert_eq!(w.diary_header, "Journal"); assert_eq!(w.listsyms, "abcde"); assert_eq!(w.listsym_rejected, "✗"); assert_eq!(w.list_syms().rejected_glyph(), '✗'); assert!(!w.listsyms_propagate); assert_eq!(w.list_margin, 2); assert_eq!(w.links_space_char, "_"); assert!(w.auto_toc); assert!(w.auto_generate_links); assert!(w.auto_generate_tags); // int 1 → true assert!(w.auto_diary_index); assert_eq!(w.toc_header, "Table of Contents"); assert_eq!(w.toc_header_level, 2); assert_eq!(w.links_header, "All Pages"); assert_eq!(w.links_header_level, 1); // unspecified → default assert_eq!(w.tags_header, "Tag Index"); assert_eq!(w.tags_header_level, 3); // custom_wiki2html keeps the literal command string (tilde-expansion is // the converter's job, not ours); args + base_url round-trip verbatim. assert_eq!(w.html.custom_wiki2html, "~/bin/my_wiki2html.sh"); assert_eq!(w.html.custom_wiki2html_args, "--flag"); assert_eq!(w.html.base_url, "https://example.com/wiki/"); // The parsed keys drive a date-mode, Sunday-start diary calendar: // a weekly note is the week-start date (YYYY-MM-DD), stepping ±7 days. use nuwiki_core::date::{DiaryDate, DiaryPeriod}; let cal = w.diary_calendar(); let wed = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 27).unwrap()); assert_eq!( cal.next(wed), DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 31).unwrap()) ); } #[test] fn diary_calendar_defaults_to_iso_weeks() { let cfg = WikiConfig::empty(); assert_eq!(cfg.diary_weekly_style, "iso"); assert_eq!(cfg.diary_start_week_day, "monday"); } #[test] fn link_severity_parses_from_client_config() { for (input, expected) in [ ("off", LinkSeverity::Off), ("hint", LinkSeverity::Hint), ("warn", LinkSeverity::Warning), ("warning", LinkSeverity::Warning), ("error", LinkSeverity::Error), ("ERROR", LinkSeverity::Error), ] { let cfg = config_from_json(json!({ "wiki_root": "/tmp/w", "diagnostic": { "link_severity": input }, })); assert_eq!( cfg.diagnostic.link_severity, expected, "input {input:?} should map to {expected:?}" ); } } #[test] fn link_severity_invalid_or_absent_falls_back_to_default() { // Unknown string → default (Warning). let bad = config_from_json(json!({ "wiki_root": "/tmp/w", "diagnostic": { "link_severity": "loud" }, })); assert_eq!(bad.diagnostic.link_severity, LinkSeverity::Warning); // No diagnostic key at all → default. let none = config_from_json(json!({ "wiki_root": "/tmp/w" })); assert_eq!(none.diagnostic.link_severity, LinkSeverity::Warning); } #[test] fn apply_change_preserves_severity_on_partial_update() { let mut cfg = config_from_json(json!({ "wiki_root": "/tmp/w", "diagnostic": { "link_severity": "error" }, })); assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Error); // A later update that omits `diagnostic` must not reset severity. cfg.apply_change(&json!({ "wiki_root": "/tmp/w2" })); assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Error); } #[test] fn apply_change_minimal_diagnostic_payload_is_not_unwrapped() { // A lone `{ "diagnostic": { … } }` is a real flat payload, not a // `{ "nuwiki": { … } }` namespace wrapper. The single-key unwrap must // skip it (it's a recognised top-level key) so the severity applies. let mut cfg = config_from_json(json!({ "wiki_root": "/tmp/w" })); assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Warning); cfg.apply_change(&json!({ "diagnostic": { "link_severity": "error" } })); assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Error); // The Neovim namespace wrapper still unwraps correctly. cfg.apply_change(&json!({ "nuwiki": { "diagnostic": { "link_severity": "hint" } } })); assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Hint); } #[test] fn severity_change_via_apply_change_is_observable_in_collected_diagnostics() { // Contract behind `did_change_configuration`'s diagnostic re-publish: // once `apply_change` updates the severity, re-running `collect_diagnostics` // (what the handler does for every open doc) must emit broken-link // diagnostics at the *new* severity. Guards the data path the handler // depends on — apply_change writing the severity AND collect_diagnostics // reading it. use nuwiki_core::syntax::vimwiki::VimwikiSyntax; use nuwiki_core::syntax::SyntaxPlugin; use nuwiki_lsp::index::WorkspaceIndex; use tower_lsp::lsp_types::DiagnosticSeverity; let root = "/tmp/cfgchange"; let src = "[[GhostPage]]\n"; let ast = VimwikiSyntax::new().parse(src); let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root))); let uri = Url::from_file_path(format!("{root}/Home.wiki")).unwrap(); idx.upsert(uri.clone(), &ast); let mut cfg = config_from_json(json!({ "wiki_root": root, "diagnostic": { "link_severity": "warn" }, })); let warn = collect_diagnostics( &ast, src, Some(&uri), Some(&idx), Some("Home"), &cfg.diagnostic, true, ); assert_eq!(warn.len(), 1, "broken link should warn"); assert_eq!(warn[0].severity, Some(DiagnosticSeverity::WARNING)); // Simulate the client raising severity via didChangeConfiguration. cfg.apply_change(&json!({ "diagnostic": { "link_severity": "error" } })); let err = collect_diagnostics( &ast, src, Some(&uri), Some(&idx), Some("Home"), &cfg.diagnostic, true, ); assert_eq!(err.len(), 1); assert_eq!( err[0].severity, Some(DiagnosticSeverity::ERROR), "re-collected diagnostics must reflect the new severity" ); } #[test] fn vim_flat_and_neovim_wrapped_payloads_desugar_identically() { // Parity guard for the two client shapes: // * Vim (`autoload/nuwiki/lsp.vim`) → flat init_options dict. // * Neovim (`lua/nuwiki/lsp.lua`) → `{ nuwiki = { … } }` for // `workspace/didChangeConfiguration` (server_settings()). // Both must produce the same wikis; otherwise switching editors would // silently change how the server resolves links/diary/HTML paths. let inner = json!({ "wiki_root": "/tmp/base", "file_extension": ".md", "syntax": "vimwiki", "log_level": "debug", "diagnostic": { "link_severity": "error" }, "wikis": [ { "name": "personal", "root": "/tmp/personal", "file_extension": ".wiki", "index": "home", "diary_rel_path": "journal", }, { "name": "work", "root": "/tmp/work" }, ], }); let flat = config_from_json(inner.clone()); let wrapped = config_from_json(json!({ "nuwiki": inner })); assert_eq!( flat.diagnostic.link_severity, wrapped.diagnostic.link_severity ); assert_eq!(flat.diagnostic.link_severity, LinkSeverity::Error); assert_eq!(flat.wikis.len(), wrapped.wikis.len()); for (a, b) in flat.wikis.iter().zip(wrapped.wikis.iter()) { assert_eq!(a.name, b.name); assert_eq!(a.root, b.root); assert_eq!(a.file_extension, b.file_extension); assert_eq!(a.syntax, b.syntax); assert_eq!(a.index, b.index); assert_eq!(a.diary_rel_path, b.diary_rel_path); } // And the desugared list matches the per-wiki values both clients send. assert_eq!(flat.wikis[0].name, "personal"); assert_eq!(flat.wikis[0].root, PathBuf::from("/tmp/personal")); assert_eq!(flat.wikis[0].file_extension, ".wiki"); assert_eq!(flat.wikis[0].index, "home"); assert_eq!(flat.wikis[0].diary_rel_path, "journal"); assert_eq!(flat.wikis[1].name, "work"); assert_eq!(flat.wikis[1].root, PathBuf::from("/tmp/work")); } #[test] fn legacy_single_wiki_shape_picks_up_defaults() { let cfg = config_from_json(serde_json::json!({ "wiki_root": "/tmp/legacy", })); assert_eq!(cfg.wikis.len(), 1); assert_eq!(cfg.wikis[0].index, "index"); assert_eq!(cfg.wikis[0].diary_frequency, "daily"); }