2026-05-13 01:30:55 +00:00
|
|
|
//! 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).
|
2026-05-11 14:32:22 +00:00
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 22:56:17 +00:00
|
|
|
#[test]
|
|
|
|
|
fn single_wiki_shorthand_honours_top_level_per_wiki_keys() {
|
|
|
|
|
// Regression: a single-wiki user sets per-wiki keys at the top level
|
|
|
|
|
// (alongside wiki_root); they must reach the synthesized wiki, not just
|
|
|
|
|
// file_extension/syntax. Previously toc_header_level etc. were dropped.
|
|
|
|
|
let cfg = config_from_json(json!({
|
|
|
|
|
"wiki_root": "/tmp/vimwiki",
|
|
|
|
|
"toc_header": "Table of Contents",
|
|
|
|
|
"toc_header_level": 2,
|
|
|
|
|
"links_header_level": 3,
|
|
|
|
|
"auto_export": true,
|
|
|
|
|
"html_path": "/tmp/out",
|
|
|
|
|
}));
|
|
|
|
|
assert_eq!(cfg.wikis.len(), 1);
|
|
|
|
|
let w = &cfg.wikis[0];
|
|
|
|
|
assert_eq!(w.toc_header, "Table of Contents");
|
|
|
|
|
assert_eq!(w.toc_header_level, 2);
|
|
|
|
|
assert_eq!(w.links_header_level, 3);
|
|
|
|
|
assert!(w.html.auto_export);
|
|
|
|
|
assert_eq!(w.html.html_path, PathBuf::from("/tmp/out"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 14:32:22 +00:00
|
|
|
#[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();
|
2026-05-11 20:49:32 +00:00
|
|
|
let diags = collect_diagnostics(&doc, "abc ok", None, None, None, &cfg, true);
|
2026-05-11 14:32:22 +00:00
|
|
|
assert_eq!(diags.len(), 1);
|
|
|
|
|
assert_eq!(diags[0].message, "broken");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-11 20:49:32 +00:00
|
|
|
fn collect_diagnostics_link_health_emits_nothing_without_index() {
|
|
|
|
|
// No index → link-health source is skipped, even when severity is on.
|
2026-05-11 14:32:22 +00:00
|
|
|
let doc = DocumentNode::default();
|
|
|
|
|
let cfg = DiagnosticConfig {
|
|
|
|
|
link_severity: LinkSeverity::Error,
|
|
|
|
|
};
|
2026-05-11 20:49:32 +00:00
|
|
|
let diags = collect_diagnostics(&doc, "", None, None, Some("Home"), &cfg, true);
|
2026-05-11 14:32:22 +00:00
|
|
|
assert!(diags.is_empty());
|
|
|
|
|
}
|
2026-05-13 01:30:55 +00:00
|
|
|
|
|
|
|
|
// ===== 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");
|
2026-05-31 16:50:59 +00:00
|
|
|
assert_eq!(cfg.diary_caption_level, 0); // upstream default
|
2026-05-13 01:30:55 +00:00
|
|
|
assert_eq!(cfg.diary_sort, "desc");
|
|
|
|
|
assert_eq!(cfg.diary_header, "Diary");
|
2026-06-02 10:50:38 +00:00
|
|
|
// diary_months defaults to the 12 English month names.
|
|
|
|
|
assert_eq!(cfg.diary_months.len(), 12);
|
|
|
|
|
assert_eq!(cfg.diary_months[0], "January");
|
|
|
|
|
assert_eq!(cfg.diary_months[11], "December");
|
2026-05-13 01:30:55 +00:00
|
|
|
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);
|
2026-05-31 18:55:51 +00:00
|
|
|
assert!(!cfg.auto_generate_links);
|
|
|
|
|
assert!(!cfg.auto_generate_tags);
|
|
|
|
|
assert!(!cfg.auto_diary_index);
|
2026-05-31 18:44:35 +00:00
|
|
|
// 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, "-");
|
2026-05-31 19:06:42 +00:00
|
|
|
// 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, "");
|
2026-06-02 00:33:01 +00:00
|
|
|
// HTML section numbering is off by default (vimwiki's `0`).
|
|
|
|
|
assert_eq!(cfg.html.html_header_numbering, 0);
|
|
|
|
|
assert_eq!(cfg.html.html_header_numbering_sym, "");
|
2026-06-03 12:04:36 +00:00
|
|
|
// New config defaults match upstream.
|
|
|
|
|
assert!(cfg.create_link);
|
|
|
|
|
assert_eq!(cfg.dir_link, "");
|
|
|
|
|
assert_eq!(cfg.bullet_types, vec!["-", "*", "#"]);
|
|
|
|
|
assert!(!cfg.cycle_bullets);
|
|
|
|
|
assert!(!cfg.generated_links_caption);
|
|
|
|
|
assert_eq!(cfg.toc_link_format, 0);
|
|
|
|
|
assert!(!cfg.table_reduce_last_col);
|
|
|
|
|
assert_eq!(cfg.html.rss_name, "rss.xml");
|
|
|
|
|
assert_eq!(cfg.html.rss_max_items, 10);
|
|
|
|
|
assert!(cfg.html.list_ignore_newline);
|
|
|
|
|
assert!(cfg.html.text_ignore_newline);
|
|
|
|
|
assert!(cfg.html.emoji_enable);
|
|
|
|
|
assert!(cfg.html.user_htmls.is_empty());
|
|
|
|
|
assert!(cfg.html.valid_html_tags.contains(&"sub".to_string()));
|
|
|
|
|
// color_dic ships a populated palette (vimwiki seeds one).
|
|
|
|
|
assert!(cfg.html.color_dic.contains_key("red"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn raw_wiki_parses_config_batch_keys() {
|
|
|
|
|
let cfg = config_from_json(serde_json::json!({
|
|
|
|
|
"wikis": [{
|
|
|
|
|
"root": "/tmp/w",
|
|
|
|
|
"create_link": false,
|
|
|
|
|
"dir_link": "index",
|
|
|
|
|
"bullet_types": ["+", "-"],
|
|
|
|
|
"cycle_bullets": true,
|
|
|
|
|
"generated_links_caption": 1,
|
|
|
|
|
"toc_link_format": 1,
|
|
|
|
|
"table_reduce_last_col": true,
|
|
|
|
|
"rss_name": "feed.xml",
|
|
|
|
|
"rss_max_items": 5,
|
|
|
|
|
"valid_html_tags": "b,mark",
|
|
|
|
|
"list_ignore_newline": 0,
|
|
|
|
|
"text_ignore_newline": false,
|
|
|
|
|
"emoji_enable": 0,
|
|
|
|
|
"user_htmls": ["404.html"],
|
|
|
|
|
"color_tag_template": "<u>__CONTENT__</u>",
|
|
|
|
|
}],
|
|
|
|
|
}));
|
|
|
|
|
let w = &cfg.wikis[0];
|
|
|
|
|
assert!(!w.create_link);
|
|
|
|
|
assert_eq!(w.dir_link, "index");
|
|
|
|
|
assert_eq!(w.bullet_types, vec!["+", "-"]);
|
|
|
|
|
assert!(w.cycle_bullets);
|
|
|
|
|
assert!(w.generated_links_caption);
|
|
|
|
|
assert_eq!(w.toc_link_format, 1);
|
|
|
|
|
assert!(w.table_reduce_last_col);
|
|
|
|
|
assert_eq!(w.html.rss_name, "feed.xml");
|
|
|
|
|
assert_eq!(w.html.rss_max_items, 5);
|
|
|
|
|
assert_eq!(w.html.valid_html_tags, vec!["b", "mark"]);
|
|
|
|
|
assert!(!w.html.list_ignore_newline);
|
|
|
|
|
assert!(!w.html.text_ignore_newline);
|
|
|
|
|
assert!(!w.html.emoji_enable);
|
|
|
|
|
assert_eq!(w.html.user_htmls, vec!["404.html"]);
|
|
|
|
|
assert_eq!(w.html.color_tag_template, "<u>__CONTENT__</u>");
|
2026-05-13 01:30:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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",
|
2026-05-31 17:06:09 +00:00
|
|
|
"diary_weekly_style": "date",
|
|
|
|
|
"diary_start_week_day": "sunday",
|
2026-05-13 01:30:55 +00:00
|
|
|
"diary_caption_level": 2,
|
|
|
|
|
"diary_sort": "asc",
|
|
|
|
|
"diary_header": "Journal",
|
2026-06-02 10:50:38 +00:00
|
|
|
"diary_months": ["Jan","Feb","Mar","Apr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"],
|
2026-05-13 01:30:55 +00:00
|
|
|
"listsyms": "abcde",
|
2026-05-31 18:33:25 +00:00
|
|
|
"listsym_rejected": "✗",
|
2026-05-13 01:30:55 +00:00
|
|
|
"listsyms_propagate": false,
|
|
|
|
|
"list_margin": 2,
|
|
|
|
|
"links_space_char": "_",
|
|
|
|
|
"auto_toc": true,
|
2026-05-31 18:55:51 +00:00
|
|
|
"auto_generate_links": true,
|
|
|
|
|
"auto_generate_tags": 1,
|
|
|
|
|
"auto_diary_index": true,
|
2026-05-31 18:44:35 +00:00
|
|
|
"toc_header": "Table of Contents",
|
|
|
|
|
"toc_header_level": 2,
|
|
|
|
|
"links_header": "All Pages",
|
|
|
|
|
"tags_header": "Tag Index",
|
|
|
|
|
"tags_header_level": 3,
|
2026-05-31 19:06:42 +00:00
|
|
|
"custom_wiki2html": "~/bin/my_wiki2html.sh",
|
|
|
|
|
"custom_wiki2html_args": "--flag",
|
|
|
|
|
"base_url": "https://example.com/wiki/",
|
2026-06-02 00:33:01 +00:00
|
|
|
"html_header_numbering": 2,
|
|
|
|
|
"html_header_numbering_sym": ".",
|
2026-05-13 01:30:55 +00:00
|
|
|
}],
|
|
|
|
|
}));
|
|
|
|
|
let w = &cfg.wikis[0];
|
|
|
|
|
assert_eq!(w.index, "home");
|
|
|
|
|
assert_eq!(w.diary_frequency, "weekly");
|
2026-05-31 17:06:09 +00:00
|
|
|
assert_eq!(w.diary_weekly_style, "date");
|
|
|
|
|
assert_eq!(w.diary_start_week_day, "sunday");
|
2026-05-13 01:30:55 +00:00
|
|
|
assert_eq!(w.diary_caption_level, 2);
|
|
|
|
|
assert_eq!(w.diary_sort, "asc");
|
|
|
|
|
assert_eq!(w.diary_header, "Journal");
|
2026-06-02 10:50:38 +00:00
|
|
|
assert_eq!(w.diary_months[4], "Mai");
|
|
|
|
|
assert_eq!(w.diary_months[11], "Dez");
|
2026-05-13 01:30:55 +00:00
|
|
|
assert_eq!(w.listsyms, "abcde");
|
2026-05-31 18:33:25 +00:00
|
|
|
assert_eq!(w.listsym_rejected, "✗");
|
|
|
|
|
assert_eq!(w.list_syms().rejected_glyph(), '✗');
|
2026-05-13 01:30:55 +00:00
|
|
|
assert!(!w.listsyms_propagate);
|
|
|
|
|
assert_eq!(w.list_margin, 2);
|
|
|
|
|
assert_eq!(w.links_space_char, "_");
|
|
|
|
|
assert!(w.auto_toc);
|
2026-05-31 18:55:51 +00:00
|
|
|
assert!(w.auto_generate_links);
|
|
|
|
|
assert!(w.auto_generate_tags); // int 1 → true
|
|
|
|
|
assert!(w.auto_diary_index);
|
2026-05-31 18:44:35 +00:00
|
|
|
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);
|
2026-05-31 19:06:42 +00:00
|
|
|
// 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/");
|
2026-06-02 00:33:01 +00:00
|
|
|
assert_eq!(w.html.html_header_numbering, 2);
|
|
|
|
|
assert_eq!(w.html.html_header_numbering_sym, ".");
|
2026-05-31 17:06:09 +00:00
|
|
|
|
|
|
|
|
// 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())
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 10:50:38 +00:00
|
|
|
#[test]
|
|
|
|
|
fn markdown_wiki_derives_list_margin_zero_when_unset() {
|
|
|
|
|
// vimwiki sets list_margin = 0 for markdown wikis (vs -1 elsewhere) when
|
|
|
|
|
// the key is absent; an explicit value still wins.
|
|
|
|
|
let cfg = config_from_json(serde_json::json!({
|
|
|
|
|
"wikis": [{ "root": "/tmp/md", "syntax": "markdown" }],
|
|
|
|
|
}));
|
|
|
|
|
assert_eq!(cfg.wikis[0].list_margin, 0);
|
|
|
|
|
|
|
|
|
|
let vw = config_from_json(serde_json::json!({
|
|
|
|
|
"wikis": [{ "root": "/tmp/vw", "syntax": "vimwiki" }],
|
|
|
|
|
}));
|
|
|
|
|
assert_eq!(vw.wikis[0].list_margin, -1);
|
|
|
|
|
|
|
|
|
|
let explicit = config_from_json(serde_json::json!({
|
|
|
|
|
"wikis": [{ "root": "/tmp/md2", "syntax": "markdown", "list_margin": 4 }],
|
|
|
|
|
}));
|
|
|
|
|
assert_eq!(explicit.wikis[0].list_margin, 4);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn diary_caption_level_accepts_negative() {
|
|
|
|
|
// vimwiki allows diary_caption_level = -1 (min: -1); the field is i8 so it
|
|
|
|
|
// parses instead of failing deserialization.
|
|
|
|
|
let cfg = config_from_json(serde_json::json!({
|
|
|
|
|
"wikis": [{ "root": "/tmp/w", "diary_caption_level": -1 }],
|
|
|
|
|
}));
|
|
|
|
|
assert_eq!(cfg.wikis[0].diary_caption_level, -1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 17:06:09 +00:00
|
|
|
#[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");
|
2026-05-13 01:30:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-30 21:53:46 -03:00
|
|
|
#[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 23:12:08 -03:00
|
|
|
#[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"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 19:24:20 -03:00
|
|
|
#[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",
|
2026-05-30 21:53:46 -03:00
|
|
|
"diagnostic": { "link_severity": "error" },
|
2026-05-30 19:24:20 -03:00
|
|
|
"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 }));
|
|
|
|
|
|
2026-05-30 21:53:46 -03:00
|
|
|
assert_eq!(
|
|
|
|
|
flat.diagnostic.link_severity,
|
|
|
|
|
wrapped.diagnostic.link_severity
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(flat.diagnostic.link_severity, LinkSeverity::Error);
|
2026-05-30 19:24:20 -03:00
|
|
|
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"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 01:30:55 +00:00
|
|
|
#[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");
|
|
|
|
|
}
|