//! 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_start_week_day, "monday"); assert_eq!(cfg.diary_caption_level, 1); assert_eq!(cfg.diary_sort, "desc"); assert_eq!(cfg.diary_header, "Diary"); assert_eq!(cfg.maxhi, 1); assert_eq!(cfg.listsyms, " .oOX"); assert!(cfg.listsyms_propagate); assert_eq!(cfg.list_margin, -1); assert_eq!(cfg.links_space_char, " "); assert!(cfg.nested_syntaxes.is_empty()); assert!(!cfg.auto_toc); } #[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_start_week_day": "sunday", "diary_caption_level": 2, "diary_sort": "asc", "diary_header": "Journal", "maxhi": 6, "listsyms": "abcde", "listsyms_propagate": false, "list_margin": 2, "links_space_char": "_", "nested_syntaxes": { "python": "python", "ruby": "ruby" }, "auto_toc": true, }], })); let w = &cfg.wikis[0]; assert_eq!(w.index, "home"); assert_eq!(w.diary_frequency, "weekly"); 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.maxhi, 6); assert_eq!(w.listsyms, "abcde"); assert!(!w.listsyms_propagate); assert_eq!(w.list_margin, 2); assert_eq!(w.links_space_char, "_"); assert_eq!( w.nested_syntaxes.get("python").map(String::as_str), Some("python") ); assert!(w.auto_toc); } #[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 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"); }