feat: initial nuwiki Rust workspace (core, lsp, ls)
CI / cargo fmt --check (push) Successful in 59s
CI / cargo clippy (push) Successful in 1m20s
CI / cargo test (push) Successful in 1m29s
CI / editor keymaps (push) Failing after 33s

This commit is contained in:
gffranco
2026-06-24 00:01:59 +00:00
commit 29a4efb6bc
70 changed files with 26264 additions and 0 deletions
@@ -0,0 +1,524 @@
//! Checkbox state transitions and the `checkbox_edit` rewriter that
//! emits `WorkspaceEdit`s for `nuwiki.list.toggleCheckbox`,
//! `nuwiki.list.cycleCheckbox`, and `nuwiki.list.rejectCheckbox`.
use nuwiki_core::listsyms::ListSyms;
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::commands::ops;
use tower_lsp::lsp_types::{DocumentChanges, Url};
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn syms() -> ListSyms {
ListSyms::default()
}
fn dummy_uri() -> Url {
Url::parse("file:///tmp/note.wiki").unwrap()
}
fn one_text_edit(edit: tower_lsp::lsp_types::WorkspaceEdit) -> tower_lsp::lsp_types::TextEdit {
let changes = edit.changes.expect("expected changes map");
let (_, edits) = changes.into_iter().next().expect("at least one uri");
assert_eq!(edits.len(), 1, "expected exactly one text edit");
edits.into_iter().next().unwrap()
}
fn doc_changes_for(edit: tower_lsp::lsp_types::WorkspaceEdit) -> Option<DocumentChanges> {
edit.document_changes
}
// ===== state transitions =====
#[test]
fn toggle_state_round_trip() {
let s = syms();
assert_eq!(ops::toggle_state(&s, "[ ]").as_deref(), Some("[X]"));
assert_eq!(ops::toggle_state(&s, "[X]").as_deref(), Some("[ ]"));
assert_eq!(ops::toggle_state(&s, "[.]").as_deref(), Some("[X]"));
assert_eq!(ops::toggle_state(&s, "[-]").as_deref(), Some("[ ]"));
assert_eq!(ops::toggle_state(&s, "[?]").as_deref(), None);
}
#[test]
fn cycle_state_walks_progression() {
let s = syms();
assert_eq!(ops::cycle_state(&s, "[ ]").as_deref(), Some("[.]"));
assert_eq!(ops::cycle_state(&s, "[.]").as_deref(), Some("[o]"));
assert_eq!(ops::cycle_state(&s, "[o]").as_deref(), Some("[O]"));
assert_eq!(ops::cycle_state(&s, "[O]").as_deref(), Some("[X]"));
assert_eq!(ops::cycle_state(&s, "[X]").as_deref(), Some("[ ]"));
assert_eq!(ops::cycle_state(&s, "[-]").as_deref(), Some("[ ]"));
}
#[test]
fn cycle_state_back_is_exact_inverse() {
// `glp` walks the progression in reverse — every step must undo the
// matching `cycle_state` step.
let s = syms();
assert_eq!(ops::cycle_state_back(&s, "[ ]").as_deref(), Some("[X]"));
assert_eq!(ops::cycle_state_back(&s, "[X]").as_deref(), Some("[O]"));
assert_eq!(ops::cycle_state_back(&s, "[O]").as_deref(), Some("[o]"));
assert_eq!(ops::cycle_state_back(&s, "[o]").as_deref(), Some("[.]"));
assert_eq!(ops::cycle_state_back(&s, "[.]").as_deref(), Some("[ ]"));
assert_eq!(ops::cycle_state_back(&s, "[-]").as_deref(), Some("[ ]"));
// Composing forward then backward returns to the start for every
// in-cycle marker.
for m in ["[ ]", "[.]", "[o]", "[O]", "[X]"] {
let fwd = ops::cycle_state(&s, m).unwrap();
assert_eq!(ops::cycle_state_back(&s, &fwd).as_deref(), Some(m));
}
}
#[test]
fn reject_state_toggles_dash_marker() {
let s = syms();
assert_eq!(ops::reject_state(&s, "[ ]").as_deref(), Some("[-]"));
assert_eq!(ops::reject_state(&s, "[X]").as_deref(), Some("[-]"));
assert_eq!(ops::reject_state(&s, "[-]").as_deref(), Some("[ ]"));
}
#[test]
fn custom_palette_three_symbols() {
// A 3-glyph palette `" x✓"`: empty/half/done. `[x]` is the only
// intermediate; cycling walks ` `→`x`→`✓`→` `.
let s = ListSyms::new(" x✓");
assert_eq!(ops::cycle_state(&s, "[ ]").as_deref(), Some("[x]"));
assert_eq!(ops::cycle_state(&s, "[x]").as_deref(), Some("[✓]"));
assert_eq!(ops::cycle_state(&s, "[✓]").as_deref(), Some("[ ]"));
// Default-palette glyphs are not in this palette → no transition.
assert_eq!(ops::cycle_state(&s, "[o]").as_deref(), None);
// Toggle still maps empty→done, done→empty.
assert_eq!(ops::toggle_state(&s, "[ ]").as_deref(), Some("[✓]"));
assert_eq!(ops::toggle_state(&s, "[✓]").as_deref(), Some("[ ]"));
}
// ===== checkbox_edit =====
#[test]
fn toggle_empty_checkbox_to_done() {
let src = "- [ ] task\n";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
0,
4,
true,
&syms(),
ops::toggle_state,
true,
)
.expect("toggle edit");
let te = one_text_edit(edit);
assert_eq!(te.new_text, "[X]");
assert_eq!(te.range.start.character, 2); // bytes 2..5 = "[ ]"
assert_eq!(te.range.end.character, 5);
}
#[test]
fn cycle_advances_partial_states() {
let src = "- [.] half\n";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
0,
0,
true,
&syms(),
ops::cycle_state,
true,
)
.expect("cycle edit");
let te = one_text_edit(edit);
assert_eq!(te.new_text, "[o]");
}
#[test]
fn reject_replaces_empty_with_dash() {
let src = "* [ ] thing\n";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
0,
0,
true,
&syms(),
ops::reject_state,
true,
)
.expect("reject edit");
let te = one_text_edit(edit);
assert_eq!(te.new_text, "[-]");
}
#[test]
fn checkbox_edit_returns_none_on_plain_list_item() {
// No `[…]` at all → no edit.
let src = "- plain item\n";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
0,
0,
true,
&syms(),
ops::toggle_state,
true,
);
assert!(edit.is_none());
}
#[test]
fn checkbox_edit_returns_none_off_list() {
let src = "just a paragraph\n";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
0,
0,
true,
&syms(),
ops::toggle_state,
true,
);
assert!(edit.is_none());
}
#[test]
fn checkbox_edit_finds_item_in_sublist() {
let src = "- top\n - [ ] nested\n";
let doc = parse(src);
// Cursor on line 1, the nested item.
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
1,
5,
true,
&syms(),
ops::toggle_state,
true,
)
.expect("edit on nested item");
let te = one_text_edit(edit);
assert_eq!(te.new_text, "[X]");
assert_eq!(te.range.start.line, 1);
}
// ===== sanity: workspace edit shape =====
#[test]
fn checkbox_edit_uses_changes_map_not_document_changes() {
let src = "- [ ] task\n";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
0,
4,
true,
&syms(),
ops::toggle_state,
true,
)
.unwrap();
assert!(doc_changes_for(edit.clone()).is_none());
assert!(edit.changes.is_some());
}
// ===== parent propagation (vimwiki listsyms_propagate) =====
/// Collect the (line, new_text) pairs for every TextEdit in the result,
/// sorted by line. Lets propagation tests assert the parent edit
/// without caring about edit ordering.
fn edits_by_line(edit: tower_lsp::lsp_types::WorkspaceEdit) -> Vec<(u32, String)> {
let changes = edit.changes.expect("expected changes map");
let (_, edits) = changes.into_iter().next().expect("at least one uri");
let mut pairs: Vec<(u32, String)> = edits
.into_iter()
.map(|e| (e.range.start.line, e.new_text))
.collect();
pairs.sort_by_key(|(line, _)| *line);
pairs
}
#[test]
fn propagate_one_of_four_done_marks_parent_quarter() {
// Toggle the first child of a 4-child parent. Parent should land at
// [.] (Quarter, 25%).
let src = "\
- [ ] root
- [ ] sub1
- [ ] sub2
- [ ] sub3
- [ ] sub4
";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
1,
6,
true,
&syms(),
ops::toggle_state,
true,
)
.expect("toggle edit");
let pairs = edits_by_line(edit);
assert_eq!(pairs, vec![(0, "[.]".into()), (1, "[X]".into())]);
}
#[test]
fn propagate_two_of_four_done_marks_parent_half() {
// sub1 is already done; toggling sub2 → done leaves rate=50% → [o].
let src = "\
- [ ] root
- [X] sub1
- [ ] sub2
- [ ] sub3
- [ ] sub4
";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
2,
6,
true,
&syms(),
ops::toggle_state,
true,
)
.expect("toggle edit");
let pairs = edits_by_line(edit);
assert_eq!(pairs, vec![(0, "[o]".into()), (2, "[X]".into())]);
}
#[test]
fn propagate_three_of_four_done_marks_parent_three_quarters() {
let src = "\
- [ ] root
- [X] sub1
- [X] sub2
- [ ] sub3
- [ ] sub4
";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
3,
6,
true,
&syms(),
ops::toggle_state,
true,
)
.expect("toggle edit");
let pairs = edits_by_line(edit);
assert_eq!(pairs, vec![(0, "[O]".into()), (3, "[X]".into())]);
}
#[test]
fn propagate_all_done_marks_parent_done() {
let src = "\
- [ ] root
- [X] sub1
- [X] sub2
- [X] sub3
- [ ] sub4
";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
4,
6,
true,
&syms(),
ops::toggle_state,
true,
)
.expect("toggle edit");
let pairs = edits_by_line(edit);
assert_eq!(pairs, vec![(0, "[X]".into()), (4, "[X]".into())]);
}
#[test]
fn propagate_untoggle_drops_parent_back() {
// Untoggling the only completed child should put parent back to [ ].
let src = "\
- [.] root
- [X] sub1
- [ ] sub2
- [ ] sub3
- [ ] sub4
";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
1,
6,
true,
&syms(),
ops::toggle_state,
true,
)
.expect("toggle edit");
let pairs = edits_by_line(edit);
assert_eq!(pairs, vec![(0, "[ ]".into()), (1, "[ ]".into())]);
}
#[test]
fn propagate_disabled_only_emits_leaf_edit() {
let src = "\
- [ ] root
- [ ] sub1
- [ ] sub2
- [ ] sub3
- [ ] sub4
";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
1,
6,
true,
&syms(),
ops::toggle_state,
false,
)
.expect("toggle edit");
let pairs = edits_by_line(edit);
assert_eq!(pairs, vec![(1, "[X]".into())]);
}
#[test]
fn propagate_skips_parent_without_checkbox() {
// Parent has no `[…]`, so propagation has nothing to update.
let src = "\
- root
- [ ] sub1
- [ ] sub2
";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
1,
6,
true,
&syms(),
ops::toggle_state,
true,
)
.expect("toggle edit");
let pairs = edits_by_line(edit);
assert_eq!(pairs, vec![(1, "[X]".into())]);
}
#[test]
fn propagate_walks_multiple_levels() {
// Toggle the deepest leaf: grandparent has 1 child (parent), parent
// has 2 children. Sub1 done → parent half → grandparent half.
let src = "\
- [ ] grand
- [ ] parent
- [ ] sub1
- [ ] sub2
";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
2,
8,
true,
&syms(),
ops::toggle_state,
true,
)
.expect("toggle edit");
let pairs = edits_by_line(edit);
assert_eq!(
pairs,
vec![(0, "[o]".into()), (1, "[o]".into()), (2, "[X]".into())]
);
}
#[test]
fn propagate_all_rejected_marks_parent_rejected() {
// Every counted child is rejected → parent becomes [-].
let src = "\
- [ ] root
- [-] sub1
- [ ] sub2
";
let doc = parse(src);
// Reject sub2 so both children are rejected.
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
2,
6,
true,
&syms(),
ops::reject_state,
true,
)
.expect("reject edit");
let pairs = edits_by_line(edit);
assert_eq!(pairs, vec![(0, "[-]".into()), (2, "[-]".into())]);
}
#[test]
fn propagate_rejected_sibling_counts_as_done_for_average() {
// sub1 rejected, sub2 toggled to done → both "complete" → parent [X].
let src = "\
- [ ] root
- [-] sub1
- [ ] sub2
";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
2,
6,
true,
&syms(),
ops::toggle_state,
true,
)
.expect("toggle edit");
let pairs = edits_by_line(edit);
assert_eq!(pairs, vec![(0, "[X]".into()), (2, "[X]".into())]);
}
@@ -0,0 +1,75 @@
//! `color_dic` → `HtmlRenderer` integration.
//!
//! Drives the renderer directly so we don't have to spin up the full
//! export pipeline. The end-to-end path (export command → renderer
//! with `cfg.color_dic` plumbed through) is integration-tested via the
//! export tests.
use std::collections::HashMap;
use nuwiki_core::ast::{ColorNode, DocumentNode, InlineNode, ParagraphNode, Span, TextNode};
use nuwiki_core::render::{HtmlRenderer, Renderer};
use nuwiki_lsp::config::config_from_json;
fn doc_with_color(name: &str, text: &str) -> DocumentNode {
let span = Span::default();
DocumentNode {
span,
metadata: Default::default(),
children: vec![nuwiki_core::ast::BlockNode::Paragraph(ParagraphNode {
span,
children: vec![InlineNode::Color(ColorNode {
span,
color: name.into(),
children: vec![InlineNode::Text(TextNode {
span,
content: text.into(),
})],
})],
})],
}
}
#[test]
fn color_falls_back_to_class_when_dict_empty() {
let doc = doc_with_color("red", "hello");
let r = HtmlRenderer::new();
let out = r.render_to_string(&doc).unwrap();
assert!(out.contains(r#"<span class="color-red">"#), "got: {out}");
assert!(out.contains("hello</span>"));
}
#[test]
fn color_dic_emits_inline_style_when_name_listed() {
let mut dict = HashMap::new();
dict.insert("red".to_string(), "#ff0000".to_string());
let doc = doc_with_color("red", "hello");
let r = HtmlRenderer::new().with_colors(dict);
let out = r.render_to_string(&doc).unwrap();
assert!(
out.contains(r#"<span style="color:#ff0000">"#),
"got: {out}"
);
}
#[test]
fn unlisted_color_falls_back_even_with_dict_set() {
let mut dict = HashMap::new();
dict.insert("red".to_string(), "#ff0000".to_string());
let doc = doc_with_color("violet", "x");
let r = HtmlRenderer::new().with_colors(dict);
let out = r.render_to_string(&doc).unwrap();
assert!(out.contains(r#"<span class="color-violet">"#), "got: {out}");
}
#[test]
fn config_from_json_accepts_color_dic() {
let cfg = config_from_json(serde_json::json!({
"wikis": [
{ "root": "/tmp/c", "color_dic": { "red": "red", "blue": "blue" } },
],
}));
let d = &cfg.wikis[0].html.color_dic;
assert_eq!(d.get("red").map(String::as_str), Some("red"));
assert_eq!(d.get("blue").map(String::as_str), Some("blue"));
}
@@ -0,0 +1,593 @@
//! Command-coverage suite: at least one behavioural test for every
//! documented `:Nuwiki*` / `:Vimwiki*` command (see the COMMANDS section
//! of `doc/nuwiki.txt`).
//!
//! The user-facing commands funnel through `workspace/executeCommand`
//! into the Backend dispatcher, or — for follow/backlinks/rename — through
//! the standard LSP requests. The Backend itself owns a live `Client` and
//! can't be built in an integration test, so each case drives the *pure*
//! building-block the command relies on (the same functions the dispatcher
//! wraps). This file is organised to mirror the doc's command groups so a
//! reader can confirm every command has coverage.
use std::collections::HashMap;
use std::path::PathBuf;
use nuwiki_core::ast::{InlineNode, LinkKind, LinkTarget};
use nuwiki_core::date::DiaryDate;
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::commands::{export_ops, ops, COMMANDS};
use nuwiki_lsp::config::WikiConfig;
use nuwiki_lsp::index::WorkspaceIndex;
use nuwiki_lsp::nav::find_inline_at;
use nuwiki_lsp::rename::{apply_links_space_char, build_new_uri, rewrite_wikilink_target};
use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki};
use nuwiki_lsp::{diary, export};
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn wiki_cfg(root: &str) -> WikiConfig {
WikiConfig::from_root(PathBuf::from(root))
}
/// Build a workspace index rooted at `root` with the given `name -> source`
/// pages, mirroring the helper used by the other command suites.
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
for (name, src) in pages {
let uri = Url::from_file_path(format!("{root}/{name}.wiki")).unwrap();
idx.upsert(uri, &parse(src));
}
idx
}
fn diary_index(root: &str, dates: &[&str]) -> WorkspaceIndex {
let mut idx =
WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into()));
for d in dates {
let uri = Url::from_file_path(format!("{root}/diary/{d}.wiki")).unwrap();
idx.upsert(uri, &parse("= entry =\n"));
}
idx
}
// =====================================================================
// Group 1: Wiki / navigation
// =====================================================================
// :NuwikiIndex — open wiki N's index page.
#[test]
fn nuwiki_index_resolves_index_page() {
let idx = build_index("/wiki", &[("index", "= Home =\n"), ("Other", "= O =\n")]);
let page = idx.page_by_name("index").expect("index page indexed");
assert!(page.uri.as_str().ends_with("/wiki/index.wiki"));
}
// :NuwikiTabIndex — same target as :NuwikiIndex, opened in a new tab.
// The tab-vs-current split is an editor concern; the resolved target is
// identical, and both verbs are advertised as executeCommands.
#[test]
fn nuwiki_tab_index_advertised_alongside_index() {
assert!(COMMANDS.contains(&"nuwiki.wiki.openIndex"));
assert!(COMMANDS.contains(&"nuwiki.wiki.tabOpenIndex"));
}
// :NuwikiUISelect — pick a wiki from the configured list.
#[test]
fn nuwiki_ui_select_lists_configured_wikis() {
let cfgs = vec![wiki_cfg("/a/personal"), wiki_cfg("/b/work")];
let wikis = build_wikis(&cfgs);
assert_eq!(wikis.len(), 2);
assert_eq!(wikis[0].config.name, "personal");
assert_eq!(wikis[1].config.name, "work");
// Selection by URI picks the wiki that actually owns the file.
let uri = Url::from_file_path("/b/work/Page.wiki").unwrap();
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(wikis[1].id));
}
// :NuwikiGoto {page} — open `{page}.wiki` by name.
#[test]
fn nuwiki_goto_opens_page_by_name() {
let idx = build_index("/wiki", &[("index", "= H =\n"), ("Recipes", "= R =\n")]);
let page = idx
.page_by_name("Recipes")
.expect("page resolvable by name");
assert!(page.uri.as_str().ends_with("/wiki/Recipes.wiki"));
assert!(idx.page_by_name("Missing").is_none());
}
// :NuwikiFollowLink — follow the link under the cursor.
#[test]
fn nuwiki_follow_link_resolves_target_under_cursor() {
let idx = build_index(
"/wiki",
&[("Home", "see [[About]]\n"), ("About", "= A =\n")],
);
let doc = parse("see [[About]]\n");
// Byte col 8 sits inside "[[About]]" (starts at byte 4).
let node = find_inline_at(&doc, 0, 8).expect("wikilink under cursor");
let InlineNode::WikiLink(w) = node else {
panic!("expected a wikilink, got {node:?}");
};
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
assert_eq!(idx.resolve(&w.target, "Home"), Some(&about));
}
// :NuwikiBacklinks — every reference to the current page.
#[test]
fn nuwiki_backlinks_lists_referrers() {
let idx = build_index(
"/wiki",
&[
("Home", "[[About]]\n"),
("Contact", "see [[About]] too\n"),
("About", "= A =\n"),
],
);
let back = idx.backlinks_for("About");
assert_eq!(back.len(), 2);
assert!(idx.backlinks_for("Home").is_empty());
}
// :NuwikiNextLink / :NuwikiPrevLink — jump to the next / previous wikilink.
// The jump itself is editor-side cursor movement; what the server provides
// is the ordered set of links on the page.
#[test]
fn nuwiki_next_prev_link_walk_links_in_document_order() {
let doc = parse("intro [[First]] mid [[Second]] end [[Third]]\n");
let links = ops::collect_wiki_links(&doc);
let targets: Vec<&str> = links
.iter()
.filter_map(|l| l.target.path.as_deref())
.collect();
assert_eq!(targets, vec!["First", "Second", "Third"]);
}
// :NuwikiBaddLink — add the target of the link under the cursor to the
// buffer list. The editor :badds whatever URI the link resolves to.
#[test]
fn nuwiki_badd_link_resolves_target_uri() {
let idx = build_index(
"/wiki",
&[("Home", "[[Notes/Todo]]\n"), ("Notes/Todo", "= T =\n")],
);
let target = LinkTarget {
kind: LinkKind::Wiki,
path: Some("Notes/Todo".into()),
..Default::default()
};
let expected = Url::from_file_path("/wiki/Notes/Todo.wiki").unwrap();
assert_eq!(idx.resolve(&target, "Home"), Some(&expected));
}
// :NuwikiRenameFile — rename the page and rewrite every inbound link.
#[test]
fn nuwiki_rename_file_builds_uri_and_rewrites_links() {
let new_uri = build_new_uri(&PathBuf::from("/wiki"), "New Name", ".wiki").unwrap();
assert!(new_uri.as_str().ends_with("/wiki/New%20Name.wiki"));
assert_eq!(
rewrite_wikilink_target("[[Old Name|caption]]", "New Name"),
Some("[[New Name|caption]]".to_string())
);
}
// links_space_char rewrites spaces in the link target / on-disk path while
// the default of a single space keeps the name verbatim.
#[test]
fn links_space_char_replaces_spaces_in_target_path() {
// Default " " — identity, spaces preserved.
assert_eq!(apply_links_space_char("My Page", " "), "My Page");
// Custom char — spaces become the configured glyph.
assert_eq!(apply_links_space_char("My Page", "_"), "My_Page");
// Subdir separators are untouched; only spaces within segments change.
assert_eq!(
apply_links_space_char("sub dir/My Page", "_"),
"sub_dir/My_Page"
);
// The transformed name flows into both the new URI and the rewritten link.
let renamed = apply_links_space_char("New Page", "-");
let new_uri = build_new_uri(&PathBuf::from("/wiki"), &renamed, ".wiki").unwrap();
assert!(new_uri.as_str().ends_with("/wiki/New-Page.wiki"));
assert_eq!(
rewrite_wikilink_target("[[Old Name|caption]]", &renamed),
Some("[[New-Page|caption]]".to_string())
);
}
// :NuwikiDeleteFile — delete the current page and its on-disk file.
#[test]
fn nuwiki_delete_file_emits_delete_workspace_edit() {
use tower_lsp::lsp_types::{DocumentChangeOperation, DocumentChanges, ResourceOp};
let uri = Url::parse("file:///wiki/Doomed.wiki").unwrap();
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
b.file_op(nuwiki_lsp::edits::op_delete(uri.clone()));
let we = b.build();
let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else {
panic!("expected resource operations");
};
assert!(matches!(
&ops[0],
DocumentChangeOperation::Op(ResourceOp::Delete(d)) if d.uri == uri
));
}
// =====================================================================
// Group 2: Diary
// =====================================================================
// :NuwikiMakeDiaryNote — open today's entry.
#[test]
fn nuwiki_make_diary_note_targets_todays_file() {
let cfg = wiki_cfg("/wiki");
let today = DiaryDate::from_ymd(2026, 5, 12).unwrap();
let uri = diary::uri_for_date(&cfg, &today).unwrap();
assert!(uri.as_str().ends_with("/diary/2026-05-12.wiki"));
}
// :NuwikiMakeYesterdayDiaryNote / :NuwikiMakeTomorrowDiaryNote — step the
// diary back / forward by one period at the daily cadence.
#[test]
fn nuwiki_yesterday_and_tomorrow_target_adjacent_files() {
let cfg = wiki_cfg("/wiki");
let yesterday = DiaryDate::from_ymd(2026, 5, 11).unwrap();
let tomorrow = DiaryDate::from_ymd(2026, 5, 13).unwrap();
assert!(diary::uri_for_date(&cfg, &yesterday)
.unwrap()
.as_str()
.ends_with("/diary/2026-05-11.wiki"));
assert!(diary::uri_for_date(&cfg, &tomorrow)
.unwrap()
.as_str()
.ends_with("/diary/2026-05-13.wiki"));
}
// :NuwikiDiaryIndex — open the diary index page.
#[test]
fn nuwiki_diary_index_resolves_index_uri() {
let cfg = wiki_cfg("/wiki");
let uri = diary::index_uri(&cfg).unwrap();
assert!(uri.as_str().ends_with("/diary/diary.wiki"));
}
// :NuwikiDiaryGenerateLinks — rebuild the diary index from disk entries.
#[test]
fn nuwiki_diary_generate_links_builds_grouped_body() {
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-04-30"]);
let entries = diary::list_entries(&idx);
let body = diary::build_index_body(
&entries,
"diary",
"Diary",
diary::DiarySort::Desc,
1,
&[],
0,
);
assert!(body.starts_with("= Diary ="));
assert!(body.contains("2026-05-12"));
assert!(body.contains("2026-05-10"));
assert!(body.contains("2026-04-30"));
// Newest entry appears before the older one within the same month.
let p12 = body.find("2026-05-12").unwrap();
let p10 = body.find("2026-05-10").unwrap();
assert!(p12 < p10, "expected newest-first ordering");
}
// :NuwikiDiaryNextDay / :NuwikiDiaryPrevDay — walk indexed entries.
#[test]
fn nuwiki_diary_next_and_prev_day_walk_entries() {
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-05-15"]);
let from = DiaryDate::from_ymd(2026, 5, 12).unwrap();
assert_eq!(
diary::next_entry(&idx, &from).unwrap().date.format(),
"2026-05-15"
);
assert_eq!(
diary::prev_entry(&idx, &from).unwrap().date.format(),
"2026-05-10"
);
}
// =====================================================================
// Group 3: Page generation
// =====================================================================
// :NuwikiTOC — generate / refresh the table of contents.
#[test]
fn nuwiki_toc_generates_nested_contents() {
let src = "= Top =\n== Sub ==\ntext\n";
let doc = parse(src);
let uri = Url::from_file_path("/wiki/Page.wiki").unwrap();
let edit =
ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0, 0, None).expect("toc edit produced");
assert!(edit.changes.is_some() || edit.document_changes.is_some());
let toc = ops::build_toc_text(
&[
(1, "Top".into(), "top".into()),
(2, "Sub".into(), "sub".into()),
],
"Contents",
1,
0,
0,
);
assert!(toc.contains("= Contents ="));
assert!(toc.contains("- [[#top|Top]]"));
assert!(toc.contains(" - [[#sub|Sub]]"));
}
// :NuwikiGenerateLinks — insert a flat list of every page in the wiki.
#[test]
fn nuwiki_generate_links_lists_all_pages_excluding_current() {
let pages = vec!["About".to_string(), "Home".to_string(), "Notes".to_string()];
let text = ops::build_links_text(&pages, "Links", 1, Some("Home"), 0, None);
assert!(text.contains("= Links ="));
assert!(text.contains("- [[About]]"));
assert!(text.contains("- [[Notes]]"));
assert!(!text.contains("[[Home]]"), "current page excluded");
let src = "= Existing =\n";
let doc = parse(src);
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
assert!(ops::links_edit(
src,
&doc,
&uri,
"Home",
&pages,
true,
"Generated Links",
1,
0,
None,
None
)
.is_some());
}
// :NuwikiCheckLinks — surface broken links across the workspace.
#[test]
fn nuwiki_check_links_distinguishes_broken_from_resolvable() {
let idx = build_index(
"/wiki",
&[("Home", "[[About]] [[Ghost]]\n"), ("About", "= A =\n")],
);
let good = LinkTarget {
kind: LinkKind::Wiki,
path: Some("About".into()),
..Default::default()
};
let broken = LinkTarget {
kind: LinkKind::Wiki,
path: Some("Ghost".into()),
..Default::default()
};
assert!(idx.resolve(&good, "Home").is_some());
assert!(
idx.resolve(&broken, "Home").is_none(),
"broken link unresolved"
);
}
// =====================================================================
// Group 4: Tags
// =====================================================================
// :NuwikiSearchTags {tag} — every `:tag:` occurrence.
#[test]
fn nuwiki_search_tags_finds_occurrences() {
let idx = build_index(
"/wiki",
&[("Home", ":alpha:beta:\n"), ("Work", ":alpha:\n")],
);
let hits = ops::tag_hits(&idx, "alpha");
assert_eq!(hits.len(), 2);
assert!(hits.iter().all(|h| h.name == "alpha"));
assert!(ops::tag_hits(&idx, "nonexistent").is_empty());
}
// :NuwikiGenerateTagLinks [tag] — section linking every page with a tag.
#[test]
fn nuwiki_generate_tag_links_builds_section() {
let idx = build_index(
"/wiki",
&[
("Home", ":alpha:\n"),
("Work", ":alpha:\n"),
("Misc", ":beta:\n"),
],
);
let by_tag = ops::tag_pages_snapshot(&idx);
let single = ops::build_tag_links_text(&by_tag, Some("alpha"), "Generated Tags", 1, 0).unwrap();
assert!(single.starts_with("= Tag: alpha ="));
assert!(single.contains("- [[Home]]"));
assert!(single.contains("- [[Work]]"));
// No-arg variant emits a section per tag.
let all = ops::build_tag_links_text(&by_tag, None, "Generated Tags", 1, 0).unwrap();
assert!(all.starts_with("= Generated Tags ="));
assert!(all.contains("== alpha =="));
assert!(all.contains("== beta =="));
let src = "= Home =\n";
let doc = parse(src);
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
assert!(ops::tag_links_edit(
src,
&doc,
&uri,
Some("alpha"),
&by_tag,
true,
"Generated Tags",
1,
0,
None
)
.is_some());
}
// :NuwikiRebuildTags — force a full workspace re-index. A rebuild must
// reflect the current on-disk tags, dropping stale ones.
#[test]
fn nuwiki_rebuild_tags_reflects_current_state() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
idx.upsert(uri.clone(), &parse(":old:\n"));
assert_eq!(idx.tags_for("old").len(), 1);
// Re-index after the page changed on disk.
idx.upsert(uri, &parse(":new:\n"));
assert!(idx.tags_for("old").is_empty(), "stale tag dropped");
assert_eq!(idx.tags_for("new").len(), 1);
}
// =====================================================================
// Group 5: HTML export
// =====================================================================
// :Nuwiki2HTML — render the current page to HTML.
#[test]
fn nuwiki_2html_renders_page() {
let cfg = wiki_cfg("/wiki");
let doc = parse("%title My Page\n= One =\nbody text\n");
let html = export::render_page_html(
&doc,
Some("<title>{{title}}</title><main>{{content}}</main>".into()),
"Home",
DiaryDate::from_ymd(2026, 5, 12).unwrap(),
&cfg.html,
Some(".wiki"),
HashMap::new(),
)
.unwrap();
assert!(html.contains("<title>My Page</title>"));
assert!(html.contains("body text"));
}
// :Nuwiki2HTMLBrowse — render then open in the browser. The render output
// is identical to :Nuwiki2HTML; the browse step is an editor-side open.
#[test]
fn nuwiki_2html_browse_shares_render_and_is_advertised() {
assert!(COMMANDS.contains(&"nuwiki.export.browse"));
let cfg = wiki_cfg("/wiki");
let doc = parse("= Page =\nhi\n");
let html = export::render_page_html(
&doc,
Some("{{content}}".into()),
"Page",
DiaryDate::today_utc(),
&cfg.html,
Some(".wiki"),
HashMap::new(),
)
.unwrap();
assert!(html.contains("hi"));
}
// :NuwikiAll2HTML[!] — export every page; honour the exclude list and
// per-page output paths.
#[test]
fn nuwiki_all2html_maps_output_paths_and_excludes() {
let cfg = wiki_cfg("/wiki");
assert!(export::output_path_for(&cfg.html, "Home").ends_with("Home.html"));
assert!(
export::output_path_for(&cfg.html, "diary/2026-05-12").ends_with("diary/2026-05-12.html")
);
let patterns = vec!["_drafts/**".to_string()];
assert!(export::is_excluded(&patterns, "_drafts/secret"));
assert!(!export::is_excluded(&patterns, "Home"));
// Both incremental and forced (`!`) variants are advertised.
assert!(COMMANDS.contains(&"nuwiki.export.allToHtml"));
assert!(COMMANDS.contains(&"nuwiki.export.allToHtmlForce"));
}
// :NuwikiRss — write `rss.xml` summarising recent diary entries.
#[test]
fn nuwiki_rss_writes_feed_file() {
let root = std::env::temp_dir().join(format!("nuwiki-cov-rss-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let cfg = WikiConfig::from_root(root.clone());
let entries = vec![
diary::DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, 10).unwrap(),
uri: Url::from_file_path(root.join("diary/2026-05-10.wiki")).unwrap(),
},
diary::DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, 12).unwrap(),
uri: Url::from_file_path(root.join("diary/2026-05-12.wiki")).unwrap(),
},
];
let path = export_ops::write_rss(&cfg, &entries).unwrap();
assert!(path.ends_with("rss.xml"));
let xml = std::fs::read_to_string(&path).unwrap();
assert!(xml.contains("<rss version=\"2.0\""));
// Newest entry listed first.
let p12 = xml.find("2026-05-12").unwrap();
let p10 = xml.find("2026-05-10").unwrap();
assert!(p12 < p10);
let _ = std::fs::remove_dir_all(&root);
}
// =====================================================================
// Group 6: Other
// =====================================================================
// :NuwikiInstall — install/re-install the `nuwiki-ls` binary. This is an
// editor-side action (binary download / cargo build); it is deliberately
// NOT an LSP executeCommand, so the server must not advertise it.
#[test]
fn nuwiki_install_is_editor_only_not_an_lsp_command() {
assert!(!COMMANDS.iter().any(|c| c.contains("install")));
}
// =====================================================================
// Command-surface cross-check: every documented command that maps to an
// executeCommand handler is actually advertised by the server.
// =====================================================================
#[test]
fn every_documented_lsp_command_is_advertised() {
let expected = [
"nuwiki.wiki.openIndex", // :NuwikiIndex
"nuwiki.wiki.tabOpenIndex", // :NuwikiTabIndex
"nuwiki.wiki.listAll", // :NuwikiUISelect
"nuwiki.wiki.gotoPage", // :NuwikiGoto
"nuwiki.file.delete", // :NuwikiDeleteFile
"nuwiki.diary.openToday", // :NuwikiMakeDiaryNote
"nuwiki.diary.openYesterday", // :NuwikiMakeYesterdayDiaryNote
"nuwiki.diary.openTomorrow", // :NuwikiMakeTomorrowDiaryNote
"nuwiki.diary.openIndex", // :NuwikiDiaryIndex
"nuwiki.diary.generateIndex", // :NuwikiDiaryGenerateLinks
"nuwiki.diary.next", // :NuwikiDiaryNextDay
"nuwiki.diary.prev", // :NuwikiDiaryPrevDay
"nuwiki.toc.generate", // :NuwikiTOC
"nuwiki.links.generate", // :NuwikiGenerateLinks
"nuwiki.workspace.checkLinks", // :NuwikiCheckLinks
"nuwiki.tags.search", // :NuwikiSearchTags
"nuwiki.tags.generateLinks", // :NuwikiGenerateTagLinks
"nuwiki.tags.rebuild", // :NuwikiRebuildTags
"nuwiki.export.currentToHtml", // :Nuwiki2HTML
"nuwiki.export.browse", // :Nuwiki2HTMLBrowse
"nuwiki.export.allToHtml", // :NuwikiAll2HTML
"nuwiki.export.rss", // :NuwikiRss
];
for cmd in expected {
assert!(COMMANDS.contains(&cmd), "server must advertise {cmd}");
}
}
+115
View File
@@ -0,0 +1,115 @@
//! Pure-function tests for the rename helper + the file-delete
//! command. The async `Backend::rename` end-to-end (with `read_or_open`
//! hitting disk) is exercised by integration tests but covered here
//! at the building-block level — same logic, no async client.
use std::path::PathBuf;
use nuwiki_lsp::rename::{build_new_uri, rewrite_wikilink_target};
use tower_lsp::lsp_types::Url;
// ===== rewrite_wikilink_target =====
#[test]
fn rewrites_plain_wikilink() {
let out = rewrite_wikilink_target("[[Old Page]]", "New Page").unwrap();
assert_eq!(out, "[[New Page]]");
}
#[test]
fn preserves_description() {
let out = rewrite_wikilink_target("[[Old|some text]]", "New").unwrap();
assert_eq!(out, "[[New|some text]]");
}
#[test]
fn preserves_anchor() {
let out = rewrite_wikilink_target("[[Old#section]]", "New").unwrap();
assert_eq!(out, "[[New#section]]");
}
#[test]
fn preserves_anchor_and_description() {
let out = rewrite_wikilink_target("[[Old#sec|desc]]", "New").unwrap();
assert_eq!(out, "[[New#sec|desc]]");
}
#[test]
fn rewrites_subdirectory_path() {
let out = rewrite_wikilink_target("[[old/page]]", "new/subdir/page").unwrap();
assert_eq!(out, "[[new/subdir/page]]");
}
#[test]
fn returns_none_for_non_wikilink_text() {
assert_eq!(rewrite_wikilink_target("just text", "X"), None);
assert_eq!(rewrite_wikilink_target("[[not closed", "X"), None);
assert_eq!(rewrite_wikilink_target("not opened]]", "X"), None);
assert_eq!(rewrite_wikilink_target("[]", "X"), None);
}
// ===== build_new_uri =====
#[test]
fn builds_uri_under_root() {
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "Page", ".wiki").unwrap();
let path = uri.to_file_path().unwrap();
assert_eq!(path, PathBuf::from("/tmp/wiki/Page.wiki"));
}
#[test]
fn builds_uri_with_subdirectory() {
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "dir/Page", ".wiki").unwrap();
let path = uri.to_file_path().unwrap();
assert_eq!(path, PathBuf::from("/tmp/wiki/dir/Page.wiki"));
}
#[test]
fn omits_duplicate_extension() {
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "Page.wiki", ".wiki").unwrap();
let path = uri.to_file_path().unwrap();
assert_eq!(path, PathBuf::from("/tmp/wiki/Page.wiki"));
}
#[test]
fn ignores_empty_path_segments() {
// `//page` should not collapse into a filesystem-absolute path.
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "//page", ".wiki").unwrap();
let path = uri.to_file_path().unwrap();
assert_eq!(path, PathBuf::from("/tmp/wiki/page.wiki"));
}
// ===== executeCommand: nuwiki.file.delete =====
//
// We don't need a Backend to drive `commands::execute` for the delete
// command — it ignores its first arg. The smoke test verifies the
// dispatcher + DeleteFile-op packaging.
#[tokio::test]
async fn file_delete_returns_workspace_edit_with_delete_op() {
use tower_lsp::lsp_types::{DocumentChangeOperation, DocumentChanges, ResourceOp};
// Building a real Backend requires a Client. For this unit test we use
// `commands::execute` with `Backend` typed as `&_` since the delete
// path doesn't touch backend state. Instead we drive the inner logic
// by constructing the args inline.
//
// Easier: shape the test as an integration test over the public
// module surface — call `commands::execute` once we expose a thin
// builder. We test the JSON-shaped contract using the
// edits module directly, since that's what `file_delete` returns.
let uri = Url::parse("file:///tmp/note.wiki").unwrap();
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
b.file_op(nuwiki_lsp::edits::op_delete(uri.clone()));
let we = b.build();
let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else {
panic!("expected Operations variant");
};
assert_eq!(ops.len(), 1);
match &ops[0] {
DocumentChangeOperation::Op(ResourceOp::Delete(d)) => {
assert_eq!(d.uri, uri);
}
other => panic!("expected DeleteFile op, got {other:?}"),
}
}
@@ -0,0 +1,83 @@
//! Heading level rewriter — `nuwiki.heading.addLevel` /
//! `nuwiki.heading.removeLevel`.
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::commands::ops;
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn dummy_uri() -> Url {
Url::parse("file:///tmp/note.wiki").unwrap()
}
fn one_text_edit(edit: tower_lsp::lsp_types::WorkspaceEdit) -> tower_lsp::lsp_types::TextEdit {
let changes = edit.changes.expect("expected changes map");
let (_, edits) = changes.into_iter().next().expect("at least one uri");
assert_eq!(edits.len(), 1, "expected exactly one text edit");
edits.into_iter().next().unwrap()
}
#[test]
fn add_heading_level_promotes_h2_to_h3() {
let src = "== Sub ==\n";
let doc = parse(src);
let edit =
ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1).expect("add_level edit");
let te = one_text_edit(edit);
assert_eq!(te.new_text, "=== Sub ===");
}
#[test]
fn add_heading_level_caps_at_6() {
let src = "====== Deep ======\n";
let doc = parse(src);
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1);
// No-op because level 6 is the cap; clamp matched current level.
assert!(edit.is_none());
}
#[test]
fn remove_heading_level_demotes() {
let src = "=== Mid ===\n";
let doc = parse(src);
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1)
.expect("remove_level edit");
let te = one_text_edit(edit);
assert_eq!(te.new_text, "== Mid ==");
}
#[test]
fn remove_heading_level_at_h1_is_noop() {
let src = "= Top =\n";
let doc = parse(src);
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1);
assert!(edit.is_none());
}
#[test]
fn heading_change_returns_none_when_cursor_not_on_heading() {
let src = "paragraph\n";
let doc = parse(src);
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1);
assert!(edit.is_none());
}
#[test]
fn rewrite_heading_with_level_keeps_leading_whitespace_outside_span() {
// The lexer's heading span starts at the first `=`, not the line's
// start, so the leading whitespace that marks a "centered" heading
// lives outside the edit range — preserved automatically. The
// rewriter's output is just the `=...=` portion.
let src = " = Title =\n";
let doc = parse(src);
let h = match &doc.children[0] {
nuwiki_core::ast::BlockNode::Heading(h) => h,
_ => panic!("expected heading"),
};
let new = ops::rewrite_heading_with_level(src, h.span, 2).unwrap();
assert_eq!(new, "== Title ==");
}
+130
View File
@@ -0,0 +1,130 @@
//! Link-related LSP commands.
//!
//! Covers:
//! - `nuwiki.link.pasteWikilink` / `nuwiki.link.pasteUrl` — page-name
//! synthesis from a URI and the textual shape the handler inserts
//! at the cursor.
//! - Wikilink resolution, including the regression where `<CR>` on a
//! wikilink to a not-yet-created page must still resolve to a URI
//! so the editor can open (and on save create) the future page.
//! The resolver itself is private; we exercise the synthesised-path
//! semantics via the public config helpers + index lookup.
use std::path::PathBuf;
use nuwiki_core::ast::LinkKind;
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::config::WikiConfig;
use nuwiki_lsp::index::{page_name_from_uri, WorkspaceIndex};
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
// ===== paste helpers =====
#[test]
fn commands_list_advertises_link_helpers() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [
"nuwiki.link.pasteWikilink",
"nuwiki.link.pasteUrl",
"nuwiki.link.catUrl",
// Both GenerateLinks forms must be advertised so coc/vim-lsp accept
// them (the scoped form was previously sent but unregistered).
"nuwiki.links.generate",
"nuwiki.links.generateForPath",
] {
assert!(names.contains(&name), "missing: {name}");
}
}
#[test]
fn page_name_for_root_level_page() {
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki"));
let uri = Url::from_file_path("/tmp/wiki/Home.wiki").unwrap();
assert_eq!(page_name_from_uri(&uri, Some(&cfg.root)), "Home");
}
#[test]
fn page_name_for_subdir_page() {
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki"));
let uri = Url::from_file_path("/tmp/wiki/diary/2026-05-11.wiki").unwrap();
assert_eq!(
page_name_from_uri(&uri, Some(&cfg.root)),
"diary/2026-05-11"
);
}
#[test]
fn paste_url_format_matches_export_output_path_for() {
// The URL the `pasteUrl` handler emits is `<name>.html`, where
// `<name>` walks path segments. Mirror that derivation and assert
// the shape we'd insert at cursor.
let name = "diary/2026-05-11";
let mut url = String::new();
for (i, seg) in name.split('/').enumerate() {
if i > 0 {
url.push('/');
}
url.push_str(seg);
}
url.push_str(".html");
assert_eq!(url, "diary/2026-05-11.html");
}
#[test]
fn paste_wikilink_shape_for_root_page() {
// `[[<name>]]` is what the handler inserts at the cursor.
let name = "Notes";
assert_eq!(format!("[[{name}]]"), "[[Notes]]");
}
// ===== resolve follow-link to non-existent page =====
#[test]
fn link_to_indexed_page_resolves_via_index() {
let root = "/tmp/follow-link-1";
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
let target = Url::from_file_path(format!("{root}/Notes.wiki")).unwrap();
idx.upsert(target.clone(), &parse("= notes =\n"));
let resolved = idx.pages_by_name.get("Notes").cloned();
assert_eq!(resolved, Some(target));
}
#[test]
fn missing_page_is_not_in_index() {
let root = "/tmp/follow-link-2";
let idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
// Empty index — "Missing" isn't there, so the fallback synthesise
// path is the only way to follow `[[Missing]]`.
assert!(!idx.pages_by_name.contains_key("Missing"));
}
#[test]
fn config_root_plus_path_plus_ext_is_a_valid_uri() {
// Sanity: the synthesise logic constructs <root>/<path>.<ext>.
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/follow-link-3"));
assert_eq!(cfg.file_extension, ".wiki");
let target_path = cfg.root.join("Missing.wiki");
assert!(Url::from_file_path(&target_path).is_ok());
}
#[test]
fn wikilink_to_subdir_page_parses_with_slash_in_path() {
// Synthesis must walk path segments, not just concatenate.
let doc = parse("[[notes/Daily]]\n");
let inline = match &doc.children[0] {
nuwiki_core::ast::BlockNode::Paragraph(p) => &p.children[0],
_ => panic!("expected paragraph"),
};
let target = match inline {
nuwiki_core::ast::InlineNode::WikiLink(w) => &w.target,
_ => panic!("expected wikilink"),
};
assert_eq!(target.kind, LinkKind::Wiki);
assert_eq!(target.path.as_deref(), Some("notes/Daily"));
}
+348
View File
@@ -0,0 +1,348 @@
//! Cluster A — list rewriters: `removeDone`, `renumber`,
//! `changeSymbol`, `changeLevel`.
use nuwiki_core::ast::ListSymbol;
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::commands::ops;
use tower_lsp::lsp_types::{Position as LspPosition, Url};
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn one_edit_text(edit: tower_lsp::lsp_types::WorkspaceEdit) -> String {
let changes = edit.changes.expect("changes map");
let (_, edits) = changes.into_iter().next().expect("≥1 uri");
edits
.into_iter()
.map(|e| e.new_text)
.collect::<Vec<_>>()
.join("\n")
}
fn uri() -> Url {
Url::parse("file:///tmp/list.wiki").unwrap()
}
// ===== parse_symbol + render_marker =====
#[test]
fn parse_symbol_accepts_short_and_long_names() {
assert_eq!(ops::parse_symbol("-"), Some(ListSymbol::Dash));
assert_eq!(ops::parse_symbol("Dash"), Some(ListSymbol::Dash));
assert_eq!(ops::parse_symbol("1."), Some(ListSymbol::Numeric));
assert_eq!(ops::parse_symbol("Numeric"), Some(ListSymbol::Numeric));
assert_eq!(ops::parse_symbol("a)"), Some(ListSymbol::AlphaParen));
assert_eq!(ops::parse_symbol("i)"), Some(ListSymbol::RomanParen));
assert!(ops::parse_symbol("nope").is_none());
}
#[test]
fn render_marker_handles_each_symbol() {
assert_eq!(ops::render_marker(ListSymbol::Dash, 1), "-");
assert_eq!(ops::render_marker(ListSymbol::Star, 1), "*");
assert_eq!(ops::render_marker(ListSymbol::Hash, 1), "#");
assert_eq!(ops::render_marker(ListSymbol::Numeric, 1), "1.");
assert_eq!(ops::render_marker(ListSymbol::Numeric, 4), "4.");
assert_eq!(ops::render_marker(ListSymbol::NumericParen, 7), "7)");
assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 1), "a)");
assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 26), "z)");
assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 27), "aa)");
assert_eq!(ops::render_marker(ListSymbol::AlphaUpperParen, 2), "B)");
assert_eq!(ops::render_marker(ListSymbol::RomanParen, 4), "iv)");
assert_eq!(ops::render_marker(ListSymbol::RomanParen, 9), "ix)");
assert_eq!(ops::render_marker(ListSymbol::RomanUpperParen, 14), "XIV)");
}
// ===== removeDone =====
#[test]
fn remove_done_strips_done_and_rejected_items() {
let src = "- [ ] todo\n- [X] done\n- [-] rejected\n- [ ] also todo\n";
let ast = parse(src);
let edit = ops::remove_done_edit(src, &ast, &uri(), None, None, true).expect("edit");
let changes = edit.changes.expect("changes");
let edits = &changes[&uri()];
assert_eq!(edits.len(), 2, "expected 2 deletions");
// Each delete edit replaces a span with empty string.
for e in edits {
assert_eq!(e.new_text, "");
}
}
#[test]
fn remove_done_leaves_open_items_alone() {
let src = "- [ ] todo\n- [.] partial\n- [o] more\n";
let ast = parse(src);
assert!(ops::remove_done_edit(src, &ast, &uri(), None, None, true).is_none());
}
#[test]
fn remove_done_returns_none_when_no_lists() {
let src = "= heading =\nparagraph\n";
let ast = parse(src);
assert!(ops::remove_done_edit(src, &ast, &uri(), None, None, true).is_none());
}
#[test]
fn remove_done_scoped_by_position_to_current_list() {
// With `position` set, the scope is the contiguous list block the
// cursor sits in — every done item in that list is removed, not just
// the one under the cursor.
let src = "- [X] done one\n- [ ] todo\n- [X] done two\n";
let ast = parse(src);
let pos = Some(LspPosition {
line: 0,
character: 0,
});
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, None, true).expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
assert_eq!(edits.len(), 2, "both done items in the current list match");
}
#[test]
fn remove_done_position_leaves_other_lists_untouched() {
// Two separate list blocks split by a paragraph. Cursor in the first
// list removes only that list's done items; the second list's done
// item survives.
let src = "- [X] done a\n- [ ] todo a\n\nparagraph\n\n- [ ] todo b\n- [X] done b\n";
let ast = parse(src);
let pos = Some(LspPosition {
line: 0,
character: 0,
});
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, None, true).expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
assert_eq!(edits.len(), 1, "only the first list's done item is removed");
}
#[test]
fn remove_done_position_cascades_into_sublists() {
// The current-list scope includes nested sublists of the block.
let src = "- [ ] parent\n - [X] done child\n - [ ] open child\n";
let ast = parse(src);
let pos = Some(LspPosition {
line: 0,
character: 0,
});
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, None, true).expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
assert_eq!(edits.len(), 1, "the nested done child is removed");
}
#[test]
fn remove_done_ranged_restricts_to_selected_lines() {
// The `:'<,'>VimwikiRemoveDone` form: a 0-indexed inclusive line range
// sweeps the whole doc but only deletes done/rejected items inside it.
// Lines: 0 `[X]`, 1 `[ ]`, 2 `[X]`, 3 `[-]`. Range 0..=1 keeps only the
// line-0 done item; the line-2/3 done+rejected items survive.
let src = "- [X] a\n- [ ] b\n- [X] c\n- [-] d\n";
let ast = parse(src);
let edit = ops::remove_done_edit(src, &ast, &uri(), None, Some((0, 1)), true).expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
assert_eq!(edits.len(), 1, "only the done item on line 0 is in range");
}
#[test]
fn remove_done_ranged_returns_none_when_range_has_no_done() {
// Range covering only open items → nothing to delete.
let src = "- [X] a\n- [ ] b\n- [ ] c\n";
let ast = parse(src);
assert!(ops::remove_done_edit(src, &ast, &uri(), None, Some((1, 2)), true).is_none());
}
// ===== renumber =====
#[test]
fn renumber_resequences_numeric_markers() {
let src = "5. first\n9. second\n2. third\n";
let ast = parse(src);
let edit = ops::renumber_edit(src, &ast, &uri(), 0, false, true).expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
// We expect 3 marker rewrites: 5.→1., 9.→2., 2.→3.
assert_eq!(edits.len(), 3);
let texts: Vec<&str> = edits.iter().map(|e| e.new_text.as_str()).collect();
assert!(texts.contains(&"1."));
assert!(texts.contains(&"2."));
assert!(texts.contains(&"3."));
}
#[test]
fn renumber_ignores_unordered_lists() {
let src = "- a\n- b\n- c\n";
let ast = parse(src);
assert!(ops::renumber_edit(src, &ast, &uri(), 0, false, true).is_none());
}
#[test]
fn renumber_whole_file_walks_every_list() {
let src = "5. one\n\n2. two\n9. three\n";
let ast = parse(src);
let edit = ops::renumber_edit(src, &ast, &uri(), 0, true, true).expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
// Two separate lists — first has 1 numeric item, second has 2 →
// 3 marker rewrites total.
assert_eq!(edits.len(), 3);
}
// ===== changeSymbol =====
#[test]
fn change_symbol_rewrites_just_the_current_item() {
let src = "- a\n- b\n- c\n";
let ast = parse(src);
let edit =
ops::change_symbol_edit(src, &ast, &uri(), 1, ListSymbol::Star, false, true).expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "*");
}
#[test]
fn change_symbol_whole_list_rewrites_every_item() {
let src = "- a\n- b\n- c\n";
let ast = parse(src);
let edit = ops::change_symbol_edit(src, &ast, &uri(), 1, ListSymbol::Numeric, true, true)
.expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
assert_eq!(edits.len(), 3);
let texts: Vec<&str> = edits.iter().map(|e| e.new_text.as_str()).collect();
assert!(texts.contains(&"1."));
assert!(texts.contains(&"2."));
assert!(texts.contains(&"3."));
}
// ===== removeCheckbox =====
#[test]
fn remove_checkbox_strips_single_item() {
let src = "- [ ] task\n- [X] done\n";
let ast = parse(src);
let edit = ops::remove_checkbox_edit(src, &ast, &uri(), 0, false, true).expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "");
// Removes `[ ] ` — columns 2..6 on the first line.
assert_eq!(edits[0].range.start.line, 0);
assert_eq!(edits[0].range.start.character, 2);
assert_eq!(edits[0].range.end.character, 6);
}
#[test]
fn remove_checkbox_whole_list_strips_every_item() {
let src = "- [ ] a\n- [X] b\n- [-] c\n";
let ast = parse(src);
let edit = ops::remove_checkbox_edit(src, &ast, &uri(), 0, true, true).expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
assert_eq!(edits.len(), 3);
for e in edits {
assert_eq!(e.new_text, "");
}
}
#[test]
fn remove_checkbox_none_without_checkbox() {
let src = "- plain item\n";
let ast = parse(src);
assert!(ops::remove_checkbox_edit(src, &ast, &uri(), 0, false, true).is_none());
}
// ===== changeLevel =====
#[test]
fn change_level_indents_single_item() {
let src = "- one\n- two\n";
let ast = parse(src);
let edit =
ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true, false, &[]).expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
assert_eq!(edits.len(), 1);
// delta=1 → +2 spaces
assert_eq!(edits[0].new_text, " ");
}
#[test]
fn change_level_dedents_clamps_to_zero() {
let src = "- root\n";
let ast = parse(src);
let edit = ops::change_level_edit(src, &ast, &uri(), 0, -1, false, true, false, &[]);
// Already at column 0 — dedent is a no-op.
assert!(edit.is_none());
}
#[test]
fn cycle_bullets_rotates_glyph_on_indent() {
// With cycle_bullets + bullet_types [-,*,#], indenting a `-` item (depth 0
// → 1) rotates the glyph to `*` (and dedenting back to `-`).
let bullets: Vec<String> = ["-", "*", "#"].iter().map(|s| s.to_string()).collect();
// Put the list well into the document so depth must come from the item's
// indentation, not its absolute byte offset (regression guard).
let src = "some intro paragraph text here\n\n- one\n- two\n";
let ast = parse(src);
let edit =
ops::change_level_edit(src, &ast, &uri(), 3, 1, false, true, true, &bullets).expect("edit");
let texts: Vec<String> = edit.changes.unwrap()[&uri()]
.iter()
.map(|e| e.new_text.clone())
.collect();
assert!(texts.contains(&" ".to_string()), "indent edit: {texts:?}");
assert!(
texts.contains(&"*".to_string()),
"glyph rotated to *: {texts:?}"
);
}
#[test]
fn cycle_bullets_off_leaves_glyph() {
let bullets: Vec<String> = ["-", "*", "#"].iter().map(|s| s.to_string()).collect();
let src = "- one\n- two\n";
let ast = parse(src);
let edit = ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true, false, &bullets)
.expect("edit");
let texts: Vec<String> = edit.changes.unwrap()[&uri()]
.iter()
.map(|e| e.new_text.clone())
.collect();
// Only the indent edit; no glyph rewrite.
assert!(
!texts.iter().any(|t| t == "*"),
"no glyph change: {texts:?}"
);
}
#[test]
fn change_level_whole_subtree_walks_descendants() {
let src = "- parent\n - child a\n - child b\n- sibling\n";
let ast = parse(src);
let edit =
ops::change_level_edit(src, &ast, &uri(), 0, 1, true, true, false, &[]).expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
// Parent + 2 children get indented. Sibling stays.
assert!(edits.len() >= 3, "got {} edits", edits.len());
}
// ===== COMMANDS list completeness =====
#[test]
fn commands_list_includes_cluster_a() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [
"nuwiki.list.removeDone",
"nuwiki.list.removeCheckbox",
"nuwiki.list.renumber",
"nuwiki.list.changeSymbol",
"nuwiki.list.changeLevel",
] {
assert!(names.contains(&name), "missing: {name}");
}
}
// Smoke a one_edit_text helper so it's exercised by at least one test.
#[test]
fn one_edit_text_round_trip() {
let src = "- [X] done\n";
let ast = parse(src);
let edit = ops::remove_done_edit(src, &ast, &uri(), None, None, true).unwrap();
let _ = one_edit_text(edit);
}
+132
View File
@@ -0,0 +1,132 @@
//! Cluster B — table rewriters: `insert`, `align`, `moveColumn`.
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::commands::ops;
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn uri() -> Url {
Url::parse("file:///tmp/tbl.wiki").unwrap()
}
fn one_text(edit: tower_lsp::lsp_types::WorkspaceEdit) -> String {
let changes = edit.changes.expect("changes");
let (_, edits) = changes.into_iter().next().unwrap();
edits.into_iter().next().unwrap().new_text
}
// ===== render_blank_table =====
#[test]
fn blank_table_has_header_separator_and_rows() {
let out = ops::render_blank_table(3, 2);
let lines: Vec<&str> = out.lines().collect();
// Header + separator + 2 data rows = 4.
assert_eq!(lines.len(), 4);
assert_eq!(lines[0], "| | | |");
assert_eq!(lines[1], "|--|--|--|");
assert_eq!(lines[2], "| | | |");
assert_eq!(lines[3], "| | | |");
}
#[test]
fn blank_table_handles_single_cell_grid() {
let out = ops::render_blank_table(1, 1);
assert_eq!(out, "| |\n|--|\n| |\n");
}
// ===== align =====
#[test]
fn align_pads_short_cells_to_widest() {
let src = "|a|bbb|c|\n|--|--|--|\n|1|2|three|\n";
let ast = parse(src);
let edit = ops::table_align_edit(src, &ast, &uri(), 0, true, false).expect("edit");
let body = one_text(edit);
let lines: Vec<&str> = body.lines().collect();
eprintln!("rendered table:");
for (i, l) in lines.iter().enumerate() {
eprintln!(" {i}: {l:?}");
}
// Column widths: max(a,1)=1 → 1, max(bbb,2)=3, max(c,three)=5.
assert_eq!(lines[0], "| a | bbb | c |");
// Separator row is repadded with `-` runs sized `width + 2` per
// column (the two padding spaces of content rows).
assert!(lines[1].starts_with("|---|"), "got: {:?}", lines[1]);
assert_eq!(lines[2], "| 1 | 2 | three |");
}
#[test]
fn align_returns_none_when_cursor_off_table() {
let src = "paragraph\n";
let ast = parse(src);
assert!(ops::table_align_edit(src, &ast, &uri(), 0, true, false).is_none());
}
#[test]
fn table_reduce_last_col_keeps_last_column_minimal() {
// With reduce_last_col=true, the last column isn't padded to its content
// width — it stays at the minimum (1), so `three` is not surrounded by
// alignment padding on the right edge.
let src = "|a|bbb|c|\n|--|--|--|\n|1|2|three|\n";
let ast = parse(src);
let edit = ops::table_align_edit(src, &ast, &uri(), 0, true, true).expect("edit");
let body = one_text(edit);
let lines: Vec<&str> = body.lines().collect();
// First two columns still align; the last column stays at width 1 (not 5),
// so the header's last cell renders `| c |` not `| c |`.
assert_eq!(lines[0], "| a | bbb | c |", "got: {:?}", lines[0]);
assert_eq!(lines[2], "| 1 | 2 | three |", "got: {:?}", lines[2]);
}
// ===== moveColumn =====
#[test]
fn move_column_right_swaps_with_neighbour() {
let src = "|a|b|c|\n|--|--|--|\n|1|2|3|\n";
let ast = parse(src);
// Cursor on first column → swap right.
let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, 1, true, false).expect("edit");
let body = one_text(edit);
let lines: Vec<&str> = body.lines().collect();
assert!(lines[0].contains("b") && lines[0].contains("a"));
// b should come before a after swapping col 0 → 1.
let line0 = lines[0];
let b_pos = line0.find('b').unwrap();
let a_pos = line0.find('a').unwrap();
assert!(b_pos < a_pos, "expected b before a; got {line0:?}");
}
#[test]
fn move_column_left_at_zero_is_noop() {
let src = "|a|b|\n|--|--|\n|1|2|\n";
let ast = parse(src);
// Cursor in column 0; left would be -1 → returns None.
let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, -1, true, false);
assert!(edit.is_none());
}
#[test]
fn move_column_returns_none_off_table() {
let src = "paragraph\n";
let ast = parse(src);
assert!(ops::table_move_column_edit(src, &ast, &uri(), 0, 0, 1, true, false).is_none());
}
// ===== COMMANDS list =====
#[test]
fn commands_list_includes_cluster_b() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [
"nuwiki.table.insert",
"nuwiki.table.align",
"nuwiki.table.moveColumn",
] {
assert!(names.contains(&name), "missing: {name}");
}
}
+417
View File
@@ -0,0 +1,417 @@
//! Tag-related LSP surface:
//! - `WorkspaceIndex` per-page tag lists + reverse `tags_by_name` map,
//! stale-tag replacement on re-upsert, and tag cleanup on `remove`.
//! - Tag commands: `nuwiki.tags.search`, `nuwiki.tags.generateLinks`,
//! `nuwiki.tags.rebuild` — `tag_hits` / `tag_pages_snapshot` /
//! `build_tag_links_text` / `tag_links_edit` driven directly so we
//! don't need a live handler.
use std::collections::BTreeMap;
use std::path::PathBuf;
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::commands::ops;
use nuwiki_lsp::index::WorkspaceIndex;
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
for (name, src) in pages {
let path = format!("{root}/{name}.wiki");
let uri = Url::from_file_path(&path).unwrap();
idx.upsert(uri, &parse(src));
}
idx
}
// ===== Workspace index: per-page tags + reverse map =====
#[test]
fn upsert_populates_per_page_tags() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
idx.upsert(uri.clone(), &parse(":alpha:beta:\n= Home =\n"));
let page = idx.page(&uri).expect("indexed");
assert_eq!(page.tags.len(), 2);
let names: Vec<_> = page.tags.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"alpha"));
assert!(names.contains(&"beta"));
}
#[test]
fn tags_by_name_aggregates_across_pages() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
idx.upsert(home.clone(), &parse(":shared:\n"));
idx.upsert(about.clone(), &parse(":shared:other:\n"));
let shared = idx.tags_for("shared");
assert_eq!(shared.len(), 2);
let uris: Vec<&Url> = shared.iter().map(|o| &o.uri).collect();
assert!(uris.contains(&&home));
assert!(uris.contains(&&about));
assert_eq!(idx.tags_for("other").len(), 1);
assert!(idx.tags_for("nonexistent").is_empty());
}
#[test]
fn re_upsert_replaces_stale_tags() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
idx.upsert(uri.clone(), &parse(":old:\n"));
assert_eq!(idx.tags_for("old").len(), 1);
idx.upsert(uri.clone(), &parse(":new:\n"));
assert!(idx.tags_for("old").is_empty());
assert_eq!(idx.tags_for("new").len(), 1);
}
#[test]
fn remove_drops_tags_and_reverse_entries() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
idx.upsert(uri.clone(), &parse(":a:b:\n"));
idx.remove(&uri);
assert!(idx.page(&uri).is_none());
assert!(idx.tags_for("a").is_empty());
assert!(idx.tags_for("b").is_empty());
}
#[test]
fn heading_scope_tag_indexed_but_not_in_metadata() {
// Tag at the third line below a heading is `Heading`-scoped; the
// metadata.tags accumulator only collects file-scope tags.
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
let doc = parse("para\n\n\n= H =\n:scoped:\n");
idx.upsert(uri.clone(), &doc);
assert_eq!(idx.tags_for("scoped").len(), 1);
assert!(doc.metadata.tags.is_empty());
}
// ===== `nuwiki.tags.search` — tag_hits =====
#[test]
fn tag_hits_empty_query_returns_every_tag() {
let idx = build_index(
"/tmp/th1",
&[
("Home", "= Home =\n:work:personal:\n"),
("Notes", "= Notes =\n:work:\n"),
],
);
let hits = ops::tag_hits(&idx, "");
let names: Vec<&str> = hits.iter().map(|h| h.name.as_str()).collect();
assert!(names.contains(&"work"));
assert!(names.contains(&"personal"));
// 2 pages tagged with `work`, 1 with `personal` → 3 hits.
assert_eq!(hits.len(), 3);
}
#[test]
fn tag_hits_substring_filter_case_insensitive() {
let idx = build_index(
"/tmp/th2",
&[("Page", "= Page =\n:release-2026:RoadMap:\n")],
);
let hits = ops::tag_hits(&idx, "RELEASE");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].name, "release-2026");
}
#[test]
fn tag_hits_orders_by_name_then_page() {
let idx = build_index(
"/tmp/th3",
&[
("Beta", "= Beta =\n:zeta:\n"),
("Alpha", "= Alpha =\n:alpha:\n"),
("Gamma", "= Gamma =\n:alpha:\n"),
],
);
let hits = ops::tag_hits(&idx, "");
let pairs: Vec<(String, String)> = hits
.iter()
.map(|h| (h.name.clone(), h.page.clone()))
.collect();
// alpha tag comes first; within it, Alpha page before Gamma; then zeta/Beta.
assert_eq!(pairs[0], ("alpha".into(), "Alpha".into()));
assert_eq!(pairs[1], ("alpha".into(), "Gamma".into()));
assert_eq!(pairs[2], ("zeta".into(), "Beta".into()));
}
#[test]
fn tag_hits_serializes_to_expected_json_shape() {
let idx = build_index("/tmp/th4", &[("Page", "= Page =\n:release:\n")]);
let hits = ops::tag_hits(&idx, "");
let v = serde_json::to_value(&hits).unwrap();
let row = &v[0];
assert_eq!(row["name"], "release");
assert!(row["uri"].is_string());
assert!(row["range"]["start"]["line"].is_number());
assert!(row["page"].is_string());
// scope is one of the three string variants.
let scope = row["scope"].as_str().unwrap();
assert!(
["file", "heading", "standalone"].contains(&scope),
"{scope}"
);
}
// ===== `nuwiki.tags.rebuild` — tag_pages_snapshot =====
#[test]
fn tag_pages_snapshot_deduplicates_pages_per_tag() {
// Same page has the tag in both file scope and heading scope —
// dedup so the rendered list doesn't show it twice.
let idx = build_index(
"/tmp/ts1",
&[("Home", "= Home =\n:release:\n== Section ==\n:release:\n")],
);
let snap = ops::tag_pages_snapshot(&idx);
assert_eq!(snap.get("release").unwrap().len(), 1);
assert_eq!(snap["release"][0], "Home");
}
#[test]
fn tag_pages_snapshot_sorts_pages_alphabetically() {
let idx = build_index(
"/tmp/ts2",
&[
("Charlie", "= Charlie =\n:topic:\n"),
("Alpha", "= Alpha =\n:topic:\n"),
("Bravo", "= Bravo =\n:topic:\n"),
],
);
let snap = ops::tag_pages_snapshot(&idx);
assert_eq!(snap["topic"], vec!["Alpha", "Bravo", "Charlie"]);
}
// ===== `nuwiki.tags.generateLinks` — build_tag_links_text =====
#[test]
fn build_tag_links_for_single_tag() {
let mut snap = BTreeMap::new();
snap.insert("release".to_string(), vec!["Alpha".into(), "Beta".into()]);
let out = ops::build_tag_links_text(&snap, Some("release"), "Generated Tags", 1, 0).unwrap();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "= Tag: release =");
assert_eq!(lines[1], "- [[Alpha]]");
assert_eq!(lines[2], "- [[Beta]]");
}
#[test]
fn build_tag_links_for_missing_tag_returns_none() {
let snap = BTreeMap::new();
assert!(ops::build_tag_links_text(&snap, Some("ghost"), "Generated Tags", 1, 0).is_none());
}
#[test]
fn build_tag_links_full_index_groups_by_tag() {
let mut snap = BTreeMap::new();
snap.insert("a".to_string(), vec!["P1".into()]);
snap.insert("b".to_string(), vec!["P2".into(), "P3".into()]);
let out = ops::build_tag_links_text(&snap, None, "Generated Tags", 1, 0).unwrap();
assert!(out.starts_with("= Generated Tags =\n"));
assert!(out.contains("== a =="));
assert!(out.contains("== b =="));
assert!(out.contains("- [[P1]]"));
assert!(out.contains("- [[P2]]"));
assert!(out.contains("- [[P3]]"));
}
#[test]
fn build_tag_links_full_index_returns_none_when_no_tags() {
let snap = BTreeMap::new();
assert!(ops::build_tag_links_text(&snap, None, "Generated Tags", 1, 0).is_none());
}
// ===== `nuwiki.tags.generateLinks` — tag_links_edit (in-buffer rewrite) =====
#[test]
fn tag_links_edit_inserts_when_section_missing() {
let src = "Some content.\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let mut snap = BTreeMap::new();
snap.insert("release".to_string(), vec!["Alpha".into()]);
let edit = ops::tag_links_edit(
src,
&ast,
&uri,
Some("release"),
&snap,
true,
"Generated Tags",
1,
0,
None,
)
.expect("got an edit");
let te = &edit.changes.unwrap()[&uri][0];
// Insertion at start.
assert_eq!(te.range.start, te.range.end);
assert!(te.new_text.contains("= Tag: release ="));
assert!(te.new_text.contains("[[Alpha]]"));
}
#[test]
fn tag_links_edit_inserts_at_cursor_line() {
// A fresh tags section goes at the cursor line, not the EOF.
let src = "= Notes =\nbody\nmore\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let mut snap = BTreeMap::new();
snap.insert("release".to_string(), vec!["Alpha".into()]);
let edit = ops::tag_links_edit(
src,
&ast,
&uri,
Some("release"),
&snap,
true,
"Generated Tags",
1,
0,
Some(1),
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
assert_eq!(te.range.start.line, 1);
assert_eq!(te.range.start.character, 0);
assert!(
te.new_text.starts_with("\n= Tag: release ="),
"got: {:?}",
te.new_text
);
}
#[test]
fn tag_links_edit_replaces_existing_section() {
let src = "= Tag: release =\n- [[Stale]]\n\n= Other =\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let mut snap = BTreeMap::new();
snap.insert("release".to_string(), vec!["Fresh".into()]);
let edit = ops::tag_links_edit(
src,
&ast,
&uri,
Some("release"),
&snap,
true,
"Generated Tags",
1,
0,
None,
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
assert_eq!(te.range.start.line, 0);
assert!(te.new_text.contains("[[Fresh]]"));
assert!(!te.new_text.contains("Stale"));
}
#[test]
fn tag_links_edit_full_index_replaces_existing_tags_section() {
let src = "= Generated Tags =\n- [[old]]\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let mut snap = BTreeMap::new();
snap.insert("alpha".to_string(), vec!["P".into()]);
let edit = ops::tag_links_edit(
src,
&ast,
&uri,
None,
&snap,
true,
"Generated Tags",
1,
0,
None,
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
assert!(te.new_text.contains("== alpha =="));
assert!(!te.new_text.contains("[[old]]"));
}
#[test]
fn tag_links_rebuild_edit_only_acts_when_index_present() {
let mut snap = BTreeMap::new();
snap.insert("a".to_string(), vec!["P".into()]);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let without = "= Home =\nbody\n";
assert!(
ops::tag_links_rebuild_edit(
without,
&parse(without),
&uri,
&snap,
true,
"Generated Tags",
1,
0
)
.is_none(),
"auto_generate_tags must not insert an index into a page that lacks one"
);
let with = "= Generated Tags =\n- [[old]]\n";
assert!(ops::tag_links_rebuild_edit(
with,
&parse(with),
&uri,
&snap,
true,
"Generated Tags",
1,
0
)
.is_some());
}
#[test]
fn tag_links_edit_returns_none_for_unknown_tag() {
let src = "hi\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let snap = BTreeMap::new();
assert!(ops::tag_links_edit(
src,
&ast,
&uri,
Some("ghost"),
&snap,
true,
"Generated Tags",
1,
0,
None
)
.is_none());
}
// ===== smoke: COMMANDS list advertises tag commands =====
#[test]
fn commands_list_includes_tag_commands() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [
"nuwiki.tags.search",
"nuwiki.tags.generateLinks",
"nuwiki.tags.rebuild",
] {
assert!(names.contains(&name), "missing: {name}");
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Task navigation (`nuwiki.list.nextTask`) plus a smoke test that the
//! `COMMANDS` registry advertises every list/heading/file handler the
//! editor surface relies on.
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::commands::ops;
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn dummy_uri() -> Url {
Url::parse("file:///tmp/note.wiki").unwrap()
}
// ===== next_task =====
#[test]
fn next_task_returns_first_unfinished() {
let src = "- [X] done\n- [ ] todo\n- [ ] later\n";
let doc = parse(src);
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found a task");
// Should be line 1 (the first [ ]).
assert_eq!(loc.range.start.line, 1);
}
#[test]
fn next_task_wraps_around_when_no_task_after_cursor() {
// Only task is on line 0; cursor on line 2 → wraps back.
let src = "- [ ] todo\n- [X] done\n- [X] also done\n";
let doc = parse(src);
let loc = ops::next_task(&doc, &dummy_uri(), src, 2, 0, true).expect("found");
assert_eq!(loc.range.start.line, 0);
}
#[test]
fn next_task_returns_none_when_no_open_tasks() {
let src = "- [X] a\n- [X] b\n";
let doc = parse(src);
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true);
assert!(loc.is_none());
}
#[test]
fn next_task_only_counts_items_with_checkboxes() {
// Plain items without a `[…]` are ignored — they're not tasks.
let src = "- plain\n- [ ] real task\n";
let doc = parse(src);
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found");
assert_eq!(loc.range.start.line, 1);
}
// ===== smoke: COMMANDS list matches registered handlers =====
#[test]
fn commands_list_is_complete() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [
"nuwiki.file.delete",
"nuwiki.list.toggleCheckbox",
"nuwiki.list.cycleCheckbox",
"nuwiki.list.rejectCheckbox",
"nuwiki.list.nextTask",
"nuwiki.heading.addLevel",
"nuwiki.heading.removeLevel",
] {
assert!(names.contains(&name), "missing: {name}");
}
}
+767
View File
@@ -0,0 +1,767 @@
//! Diary feature tests:
//! - Date primitives, path conventions, nav helpers, IndexedPage diary
//! classification.
//! - Frequency wiring (daily/weekly/monthly/yearly): per-frequency
//! path/URI shape, index recognition, and `next_period` / `prev_period`
//! navigation across mixed-frequency entries.
use std::path::PathBuf;
use nuwiki_core::date::{DiaryDate, DiaryFrequency, DiaryPeriod};
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::config::WikiConfig;
use nuwiki_lsp::diary;
use nuwiki_lsp::index::{diary_period_for_uri, WorkspaceIndex};
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn wiki_cfg(root: &str) -> WikiConfig {
let mut cfg = WikiConfig::from_root(PathBuf::from(root));
cfg.diary_rel_path = "diary".into();
cfg.diary_index = "diary".into();
cfg
}
fn build_index_with_entries(root: &str, dates: &[&str]) -> WorkspaceIndex {
let mut idx =
WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into()));
for d in dates {
let path = format!("{root}/diary/{d}.wiki");
let uri = Url::from_file_path(&path).unwrap();
idx.upsert(uri, &parse("= entry =\n"));
}
idx
}
// ===== DiaryDate parser =====
#[test]
fn parse_accepts_canonical_iso8601() {
let d = DiaryDate::parse("2026-05-11").unwrap();
assert_eq!(d.year, 2026);
assert_eq!(d.month, 5);
assert_eq!(d.day, 11);
}
#[test]
fn parse_rejects_missing_zero_padding() {
assert!(DiaryDate::parse("2026-5-11").is_none());
assert!(DiaryDate::parse("2026-05-1").is_none());
}
#[test]
fn parse_rejects_alternate_separators() {
assert!(DiaryDate::parse("2026/05/11").is_none());
assert!(DiaryDate::parse("2026.05.11").is_none());
}
#[test]
fn parse_rejects_extra_whitespace() {
assert!(DiaryDate::parse(" 2026-05-11").is_none());
assert!(DiaryDate::parse("2026-05-11 ").is_none());
}
#[test]
fn parse_rejects_invalid_calendar_dates() {
assert!(DiaryDate::parse("2026-13-01").is_none(), "month 13");
assert!(DiaryDate::parse("2026-02-30").is_none(), "feb 30");
assert!(DiaryDate::parse("2026-04-31").is_none(), "april 31");
assert!(DiaryDate::parse("2026-00-10").is_none(), "month 0");
assert!(DiaryDate::parse("2026-05-00").is_none(), "day 0");
}
#[test]
fn parse_accepts_leap_day_in_leap_year() {
assert!(DiaryDate::parse("2024-02-29").is_some(), "2024 is leap");
assert!(DiaryDate::parse("2023-02-29").is_none(), "2023 not leap");
assert!(DiaryDate::parse("2000-02-29").is_some(), "div by 400");
assert!(
DiaryDate::parse("1900-02-29").is_none(),
"div by 100 not 400"
);
}
#[test]
fn format_round_trip() {
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
assert_eq!(d.format(), "2026-05-11");
assert_eq!(DiaryDate::parse(&d.format()), Some(d));
}
// ===== Date arithmetic =====
#[test]
fn next_day_wraps_month_boundary() {
let last_of_jan = DiaryDate::from_ymd(2026, 1, 31).unwrap();
assert_eq!(
last_of_jan.next_day(),
DiaryDate::from_ymd(2026, 2, 1).unwrap()
);
}
#[test]
fn next_day_wraps_year_boundary() {
let nye = DiaryDate::from_ymd(2025, 12, 31).unwrap();
assert_eq!(nye.next_day(), DiaryDate::from_ymd(2026, 1, 1).unwrap());
}
#[test]
fn prev_day_walks_back_across_month() {
let first_of_mar = DiaryDate::from_ymd(2026, 3, 1).unwrap();
assert_eq!(
first_of_mar.prev_day(),
DiaryDate::from_ymd(2026, 2, 28).unwrap()
);
}
#[test]
fn prev_day_walks_back_across_leap() {
let first_of_mar = DiaryDate::from_ymd(2024, 3, 1).unwrap();
assert_eq!(
first_of_mar.prev_day(),
DiaryDate::from_ymd(2024, 2, 29).unwrap()
);
}
#[test]
fn ordering_is_chronological() {
let a = DiaryDate::from_ymd(2026, 1, 1).unwrap();
let b = DiaryDate::from_ymd(2026, 1, 2).unwrap();
let c = DiaryDate::from_ymd(2026, 2, 1).unwrap();
let d = DiaryDate::from_ymd(2027, 1, 1).unwrap();
assert!(a < b);
assert!(b < c);
assert!(c < d);
}
#[test]
fn today_utc_is_a_real_calendar_date() {
let t = DiaryDate::today_utc();
assert!(t.year >= 2020 && t.year <= 2100);
assert!((1..=12).contains(&t.month));
assert!((1..=31).contains(&t.day));
}
// ===== WikiConfig diary paths =====
#[test]
fn diary_dir_joins_root_and_rel_path() {
let cfg = wiki_cfg("/tmp/wiki");
assert_eq!(cfg.diary_dir(), PathBuf::from("/tmp/wiki/diary"));
}
#[test]
fn diary_index_path_uses_diary_index_filename_and_extension() {
let cfg = wiki_cfg("/tmp/wiki");
assert_eq!(
cfg.diary_index_path(),
PathBuf::from("/tmp/wiki/diary/diary.wiki")
);
}
#[test]
fn diary_path_for_uses_iso_date_as_stem() {
let cfg = wiki_cfg("/tmp/wiki");
let date = DiaryDate::from_ymd(2026, 5, 11).unwrap();
assert_eq!(
cfg.diary_path_for(&date),
PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki")
);
}
#[test]
fn diary_uri_for_date_is_file_url() {
let cfg = wiki_cfg("/tmp/wiki");
let date = DiaryDate::from_ymd(2026, 5, 11).unwrap();
let uri = diary::uri_for_date(&cfg, &date).unwrap();
assert!(uri.as_str().ends_with("/diary/2026-05-11.wiki"));
}
#[test]
fn diary_index_uri_resolves() {
let cfg = wiki_cfg("/tmp/wiki");
let uri = diary::index_uri(&cfg).unwrap();
assert!(uri.as_str().ends_with("/diary/diary.wiki"));
}
// ===== Index integration: diary_date on IndexedPage =====
#[test]
fn upsert_marks_diary_entries() {
let root = "/tmp/diary1";
let idx = build_index_with_entries(root, &["2026-05-11"]);
let uri = Url::from_file_path(format!("{root}/diary/2026-05-11.wiki")).unwrap();
let page = idx.page(&uri).expect("page");
let date = page.diary_date.expect("diary_date");
assert_eq!(date.format(), "2026-05-11");
}
#[test]
fn upsert_does_not_mark_non_diary_files() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary2")))
.with_diary_rel_path(Some("diary".into()));
let uri = Url::from_file_path("/tmp/diary2/Home.wiki").unwrap();
idx.upsert(uri.clone(), &parse("= Home =\n"));
assert!(idx.page(&uri).unwrap().diary_date.is_none());
}
#[test]
fn upsert_does_not_mark_dates_outside_diary_subdir() {
// A file at root named like a date but not under diary/ → not a
// diary entry.
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary3")))
.with_diary_rel_path(Some("diary".into()));
let uri = Url::from_file_path("/tmp/diary3/2026-05-11.wiki").unwrap();
idx.upsert(uri.clone(), &parse("= note =\n"));
assert!(idx.page(&uri).unwrap().diary_date.is_none());
}
// ===== Diary listing + navigation =====
#[test]
fn list_entries_returns_sorted_ascending() {
let idx = build_index_with_entries("/tmp/diary4", &["2026-05-11", "2026-04-30", "2026-05-12"]);
let entries = diary::list_entries(&idx);
let dates: Vec<String> = entries.iter().map(|e| e.date.format()).collect();
assert_eq!(dates, vec!["2026-04-30", "2026-05-11", "2026-05-12"]);
}
#[test]
fn list_entries_filtered_by_year_and_month() {
let idx = build_index_with_entries(
"/tmp/diary5",
&["2025-12-31", "2026-01-01", "2026-05-11", "2026-05-12"],
);
let may = diary::list_entries_filtered(&idx, Some(2026), Some(5));
assert_eq!(may.len(), 2);
let y_only = diary::list_entries_filtered(&idx, Some(2026), None);
assert_eq!(y_only.len(), 3);
}
#[test]
fn next_entry_finds_strictly_greater() {
let idx = build_index_with_entries("/tmp/diary6", &["2026-05-10", "2026-05-12", "2026-05-15"]);
let from = DiaryDate::from_ymd(2026, 5, 11).unwrap();
let next = diary::next_entry(&idx, &from).unwrap();
assert_eq!(next.date.format(), "2026-05-12");
}
#[test]
fn next_entry_none_when_no_future_entries() {
let idx = build_index_with_entries("/tmp/diary7", &["2026-05-10"]);
let from = DiaryDate::from_ymd(2026, 5, 11).unwrap();
assert!(diary::next_entry(&idx, &from).is_none());
}
#[test]
fn prev_entry_finds_strictly_lesser() {
let idx = build_index_with_entries("/tmp/diary8", &["2026-05-10", "2026-05-12", "2026-05-15"]);
let from = DiaryDate::from_ymd(2026, 5, 13).unwrap();
let prev = diary::prev_entry(&idx, &from).unwrap();
assert_eq!(prev.date.format(), "2026-05-12");
}
// ===== Index body generation =====
#[test]
fn build_index_body_is_newest_first_grouped_by_year_month() {
let idx = build_index_with_entries(
"/tmp/diary9",
&["2025-12-31", "2026-05-11", "2026-05-12", "2026-04-30"],
);
let entries = diary::list_entries(&idx);
let body = diary::build_index_body(
&entries,
"diary",
"Diary",
diary::DiarySort::Desc,
1,
&[],
0,
);
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines[0], "= Diary =");
// First date in output should be the latest: 2026-05-12
let earliest_2026_pos = body.find("2026-05-12").unwrap();
let latest_2025_pos = body.find("2025-12-31").unwrap();
assert!(earliest_2026_pos < latest_2025_pos, "newer entries first");
// Year headings appear
assert!(body.contains("== 2026 =="));
assert!(body.contains("== 2025 =="));
// Month headings appear
assert!(body.contains("=== May ==="));
assert!(body.contains("=== December ==="));
assert!(body.contains("=== April ==="));
}
#[test]
fn build_index_body_empty_when_no_entries() {
let body = diary::build_index_body(&[], "diary", "Diary", diary::DiarySort::Desc, 1, &[], 0);
assert_eq!(body, "= Diary =\n");
}
#[test]
fn build_index_body_ascending_sort_lists_oldest_first() {
let idx = build_index_with_entries("/tmp/diarySort", &["2026-05-11", "2026-05-12"]);
let entries = diary::list_entries(&idx);
let body =
diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Asc, 1, &[], 0);
let older = body.find("2026-05-11").unwrap();
let newer = body.find("2026-05-12").unwrap();
assert!(older < newer, "ascending sort lists oldest first");
}
#[test]
fn build_index_body_caption_level_shifts_all_headings() {
let idx = build_index_with_entries("/tmp/diaryCap", &["2026-05-11"]);
let entries = diary::list_entries(&idx);
let body = diary::build_index_body(
&entries,
"diary",
"Diary",
diary::DiarySort::Desc,
2,
&[],
0,
);
assert!(body.contains("== Diary =="), "caption at level 2");
assert!(
body.contains("=== 2026 ==="),
"year nests one below caption"
);
assert!(
body.contains("==== May ===="),
"month nests two below caption"
);
}
#[test]
fn build_index_body_clamps_caption_level_to_six() {
let idx = build_index_with_entries("/tmp/diaryClamp", &["2026-05-11"]);
let entries = diary::list_entries(&idx);
// caption_level 6 → year/month would be 7/8 but clamp to 6.
let body = diary::build_index_body(
&entries,
"diary",
"Diary",
diary::DiarySort::Desc,
6,
&[],
0,
);
assert!(body.contains("====== Diary ======"));
assert!(!body.contains("======="), "no heading exceeds level 6");
}
#[test]
fn build_index_body_uses_custom_diary_months() {
let idx = build_index_with_entries("/tmp/diaryMonths", &["2026-05-11", "2026-12-01"]);
let entries = diary::list_entries(&idx);
// A localized month table (Portuguese) replaces the English labels.
let months: Vec<String> = [
"Janeiro",
"Fevereiro",
"Março",
"Abril",
"Maio",
"Junho",
"Julho",
"Agosto",
"Setembro",
"Outubro",
"Novembro",
"Dezembro",
]
.iter()
.map(|s| s.to_string())
.collect();
let body = diary::build_index_body(
&entries,
"diary",
"Diary",
diary::DiarySort::Desc,
1,
&months,
0,
);
assert!(body.contains("=== Maio ==="), "May → Maio: {body}");
assert!(
body.contains("=== Dezembro ==="),
"December → Dezembro: {body}"
);
assert!(!body.contains("May"), "English label leaked: {body}");
}
#[test]
fn build_index_body_falls_back_per_missing_month_slot() {
let idx = build_index_with_entries("/tmp/diaryShort", &["2026-05-11"]);
let entries = diary::list_entries(&idx);
// A too-short table only covers JanMar; May falls back to English.
let months: Vec<String> = ["Jan", "Feb", "Mar"]
.iter()
.map(|s| s.to_string())
.collect();
let body = diary::build_index_body(
&entries,
"diary",
"Diary",
diary::DiarySort::Desc,
1,
&months,
0,
);
assert!(
body.contains("=== May ==="),
"missing slot → English: {body}"
);
}
#[test]
fn build_index_body_clamps_negative_caption_level_to_zero() {
let idx = build_index_with_entries("/tmp/diaryNeg", &["2026-05-11"]);
let entries = diary::list_entries(&idx);
// vimwiki allows diary_caption_level = -1; nuwiki clamps it to 0 (same as
// a caption_level of 0: caption h1, year h1, month h2).
let body = diary::build_index_body(
&entries,
"diary",
"Diary",
diary::DiarySort::Desc,
-1,
&[],
0,
);
assert!(body.contains("= Diary ="), "caption at level 1: {body}");
assert!(body.contains("= 2026 ="), "year at level 1: {body}");
assert!(body.contains("== May =="), "month at level 2: {body}");
}
#[test]
fn render_index_body_round_trip() {
let cfg = wiki_cfg("/tmp/diaryA");
let idx = build_index_with_entries("/tmp/diaryA", &["2026-05-11"]);
let body = diary::render_index_body(&cfg, &idx);
assert!(body.contains("= Diary ="));
assert!(body.contains("[[diary/2026-05-11]]"));
}
#[test]
fn render_index_body_honours_custom_header_sort_and_caption() {
let mut cfg = wiki_cfg("/tmp/diaryCustom");
cfg.diary_header = "Journal".into();
cfg.diary_sort = "asc".into();
cfg.diary_caption_level = 2;
let idx = build_index_with_entries("/tmp/diaryCustom", &["2026-05-11", "2026-05-12"]);
let body = diary::render_index_body(&cfg, &idx);
assert!(
body.contains("== Journal =="),
"custom header at caption level 2"
);
let older = body.find("2026-05-11").unwrap();
let newer = body.find("2026-05-12").unwrap();
assert!(older < newer, "asc sort honoured");
}
// ===== is_in_diary =====
#[test]
fn is_in_diary_recognises_subdir_paths() {
let cfg = wiki_cfg("/tmp/wiki");
assert!(diary::is_in_diary(
&cfg,
&PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki")
));
assert!(!diary::is_in_diary(
&cfg,
&PathBuf::from("/tmp/wiki/Home.wiki")
));
}
// ===== Serde =====
#[test]
fn diary_entry_serializes_to_date_string_and_uri() {
let uri = Url::parse("file:///tmp/wiki/diary/2026-05-11.wiki").unwrap();
let entry = diary::DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
uri,
};
let v = serde_json::to_value(&entry).unwrap();
assert_eq!(v["date"], "2026-05-11");
assert!(v["uri"].as_str().unwrap().contains("2026-05-11.wiki"));
}
// ===== COMMANDS list completeness =====
#[test]
fn commands_list_includes_diary_entries() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [
"nuwiki.diary.openToday",
"nuwiki.diary.openYesterday",
"nuwiki.diary.openTomorrow",
"nuwiki.diary.openIndex",
"nuwiki.diary.generateIndex",
"nuwiki.diary.next",
"nuwiki.diary.prev",
"nuwiki.diary.listEntries",
"nuwiki.diary.openForDate",
] {
assert!(names.contains(&name), "missing: {name}");
}
}
// ===== Config wire format =====
#[test]
fn wiki_config_defaults_match_vimwiki() {
let cfg = WikiConfig::empty();
assert_eq!(cfg.diary_rel_path, "diary");
assert_eq!(cfg.diary_index, "diary");
}
#[test]
fn wiki_config_from_root_uses_default_diary_paths() {
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/x"));
assert_eq!(cfg.diary_rel_path, "diary");
}
#[test]
fn config_from_json_accepts_custom_diary_paths() {
use nuwiki_lsp::config::config_from_json;
let cfg = config_from_json(serde_json::json!({
"wikis": [
{ "root": "/tmp/p", "diary_rel_path": "journal", "diary_index": "index" },
],
}));
assert_eq!(cfg.wikis[0].diary_rel_path, "journal");
assert_eq!(cfg.wikis[0].diary_index, "index");
}
// ===== Frequency wiring (daily / weekly / monthly / yearly) =====
fn cfg(root: &str, frequency: &str) -> WikiConfig {
let mut c = WikiConfig::from_root(PathBuf::from(root));
c.diary_rel_path = "diary".into();
c.diary_frequency = frequency.into();
c
}
#[test]
fn frequency_helper_picks_up_config_value() {
assert_eq!(cfg("/tmp", "daily").frequency(), DiaryFrequency::Daily);
assert_eq!(cfg("/tmp", "weekly").frequency(), DiaryFrequency::Weekly);
assert_eq!(cfg("/tmp", "monthly").frequency(), DiaryFrequency::Monthly);
assert_eq!(cfg("/tmp", "yearly").frequency(), DiaryFrequency::Yearly);
}
#[test]
fn diary_path_for_period_picks_right_stem_per_frequency() {
let c = cfg("/wiki", "daily");
let day = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap());
let wk = DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
};
let mo = DiaryPeriod::Month {
year: 2026,
month: 5,
};
let yr = DiaryPeriod::Year { year: 2026 };
assert_eq!(
c.diary_path_for_period(&day),
PathBuf::from("/wiki/diary/2026-05-12.wiki")
);
assert_eq!(
c.diary_path_for_period(&wk),
PathBuf::from("/wiki/diary/2026-W19.wiki")
);
assert_eq!(
c.diary_path_for_period(&mo),
PathBuf::from("/wiki/diary/2026-05.wiki")
);
assert_eq!(
c.diary_path_for_period(&yr),
PathBuf::from("/wiki/diary/2026.wiki")
);
}
#[test]
fn uri_for_period_produces_file_url_per_frequency() {
let c = cfg("/wiki", "weekly");
let wk = DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
};
let u = diary::uri_for_period(&c, &wk).unwrap();
assert!(
u.as_str().ends_with("/wiki/diary/2026-W19.wiki"),
"got: {u}"
);
}
fn make_index_with(entries: &[&str]) -> WorkspaceIndex {
let root = PathBuf::from("/wiki");
let mut idx = WorkspaceIndex::new(Some(root.clone())).with_diary_rel_path(Some("diary".into()));
for stem in entries {
let path = format!("/wiki/diary/{stem}.wiki");
let uri = Url::from_file_path(&path).unwrap();
let ast = VimwikiSyntax::new().parse("= entry =\n");
idx.upsert(uri, &ast);
}
idx
}
#[test]
fn index_recognises_all_four_frequency_stems() {
let idx = make_index_with(&["2026-05-12", "2026-W19", "2026-05", "2026"]);
for (stem, expected) in [
(
"2026-05-12",
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()),
),
(
"2026-W19",
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
},
),
(
"2026-05",
DiaryPeriod::Month {
year: 2026,
month: 5,
},
),
("2026", DiaryPeriod::Year { year: 2026 }),
] {
let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap();
let page = idx.page(&uri).expect(stem);
assert_eq!(
page.diary_period,
Some(expected),
"stem {stem} should index as {expected:?}"
);
}
}
#[test]
fn index_only_sets_diary_date_for_daily_entries() {
let idx = make_index_with(&["2026-05-12", "2026-W19"]);
let daily = Url::from_file_path("/wiki/diary/2026-05-12.wiki").unwrap();
let weekly = Url::from_file_path("/wiki/diary/2026-W19.wiki").unwrap();
assert!(idx.page(&daily).unwrap().diary_date.is_some());
assert!(idx.page(&weekly).unwrap().diary_date.is_none());
assert!(idx.page(&weekly).unwrap().diary_period.is_some());
}
#[test]
fn diary_period_for_uri_rejects_paths_outside_diary_dir() {
// Non-diary path with a date-looking stem — should NOT count.
let uri = Url::from_file_path("/wiki/notes/2026-05-12.wiki").unwrap();
let root = PathBuf::from("/wiki");
assert!(diary_period_for_uri(&uri, Some(&root), Some("diary")).is_none());
}
#[test]
fn next_prev_period_navigate_at_the_same_frequency() {
// Mix entries at different frequencies. Navigation should stay
// within the same flavour as the pivot.
let idx = make_index_with(&[
"2026-W18",
"2026-W19",
"2026-W20", // weekly
"2026-04",
"2026-05",
"2026-06", // monthly
"2026-05-10",
"2026-05-12", // daily
]);
let pivot_w = DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
};
let next_w = diary::next_period(&idx, &pivot_w).unwrap();
assert_eq!(
next_w.period,
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 20
}
);
let prev_w = diary::prev_period(&idx, &pivot_w).unwrap();
assert_eq!(
prev_w.period,
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 18
}
);
let pivot_m = DiaryPeriod::Month {
year: 2026,
month: 5,
};
let next_m = diary::next_period(&idx, &pivot_m).unwrap();
assert_eq!(
next_m.period,
DiaryPeriod::Month {
year: 2026,
month: 6
}
);
let prev_m = diary::prev_period(&idx, &pivot_m).unwrap();
assert_eq!(
prev_m.period,
DiaryPeriod::Month {
year: 2026,
month: 4
}
);
}
#[test]
fn next_prev_period_returns_none_past_the_edge() {
let idx = make_index_with(&["2026-W19"]);
let only = DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
};
assert!(diary::next_period(&idx, &only).is_none());
assert!(diary::prev_period(&idx, &only).is_none());
}
#[test]
fn diary_period_for_uri_handles_each_frequency_under_diary_dir() {
let root = PathBuf::from("/wiki");
for (stem, expected) in [
(
"2026-05-12",
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()),
),
(
"2026-W19",
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
},
),
(
"2026-05",
DiaryPeriod::Month {
year: 2026,
month: 5,
},
),
("2026", DiaryPeriod::Year { year: 2026 }),
] {
let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap();
let got = diary_period_for_uri(&uri, Some(&root), Some("diary"));
assert_eq!(got, Some(expected), "stem {stem}");
}
}
+111
View File
@@ -0,0 +1,111 @@
//! LSP folding-range provider.
//!
//! Drives `folding::folding_ranges` directly. The handler wiring is
//! shallow — it just calls into this function — so we verify the
//! shapes here and trust the integration via the LSP test harness in
//! `lsp_helpers.rs`.
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::folding;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
#[test]
fn no_headings_no_lists_emits_no_folds() {
let src = "just text\non two lines\n";
let ast = parse(src);
let folds = folding::folding_ranges(&ast, folding::line_count(src));
assert!(folds.is_empty());
}
#[test]
fn single_heading_folds_to_end_of_document() {
let src = "= Title =\nbody line 1\nbody line 2\n";
let ast = parse(src);
let folds = folding::folding_ranges(&ast, folding::line_count(src));
assert_eq!(folds.len(), 1);
assert_eq!(folds[0].start_line, 0);
// body line 2 is line 2; trailing newline pushes line_count to 4 so
// last_line == 3 (the empty line after the final newline).
assert!(folds[0].end_line >= 2);
}
#[test]
fn sibling_headings_each_get_their_own_fold() {
let src = "= One =\nbody\n= Two =\nbody2\n= Three =\n";
let ast = parse(src);
let folds = folding::folding_ranges(&ast, folding::line_count(src));
let heading_folds: Vec<_> = folds
.iter()
.filter(|f| f.start_line == 0 || f.start_line == 2 || f.start_line == 4)
.collect();
assert_eq!(heading_folds.len(), 3, "got: {folds:?}");
// One closes before Two
assert_eq!(heading_folds[0].end_line, 1);
// Two closes before Three
assert_eq!(heading_folds[1].end_line, 3);
}
#[test]
fn nested_heading_folds_inside_parent() {
let src = "= Outer =\nintro\n== Inner ==\nbody\n= Other =\n";
let ast = parse(src);
let folds = folding::folding_ranges(&ast, folding::line_count(src));
// Outer fold spans up to the line before `= Other =` (line 4).
let outer = folds
.iter()
.find(|f| f.start_line == 0)
.expect("outer fold");
assert_eq!(outer.end_line, 3);
// Inner fold is bounded by the next h1 too (since the only following
// heading is h1, which has level <= 2).
let inner = folds
.iter()
.find(|f| f.start_line == 2)
.expect("inner fold");
assert_eq!(inner.end_line, 3);
}
#[test]
fn top_level_list_gets_its_own_fold() {
let src = "before\n- item one\n- item two\n- item three\nafter\n";
let ast = parse(src);
let folds = folding::folding_ranges(&ast, folding::line_count(src));
let list_fold = folds.iter().find(|f| f.start_line == 1);
assert!(list_fold.is_some(), "expected a list fold: {folds:?}");
}
#[test]
fn single_line_blocks_are_not_folded() {
let src = "= Solo =\n";
let ast = parse(src);
let folds = folding::folding_ranges(&ast, folding::line_count(src));
// A heading on the only line of the document covers nothing
// foldable, so it's omitted.
let none_for_solo = folds
.iter()
.all(|f| !(f.start_line == 0 && f.end_line == 0));
assert!(none_for_solo, "got: {folds:?}");
}
#[test]
fn fold_kind_is_region() {
use tower_lsp::lsp_types::FoldingRangeKind;
let src = "= H =\nbody\n";
let ast = parse(src);
let folds = folding::folding_ranges(&ast, folding::line_count(src));
assert!(!folds.is_empty());
assert_eq!(folds[0].kind, Some(FoldingRangeKind::Region));
}
#[test]
fn line_count_matches_intuitive_definition() {
assert_eq!(folding::line_count(""), 0);
assert_eq!(folding::line_count("one"), 1);
assert_eq!(folding::line_count("one\n"), 2); // trailing newline → virtual empty line
assert_eq!(folding::line_count("one\ntwo"), 2);
assert_eq!(folding::line_count("one\ntwo\n"), 3);
}
+255
View File
@@ -0,0 +1,255 @@
//! Tests for the pure helpers that the LSP backend uses to translate
//! between `nuwiki-core` ASTs and the LSP wire format. The async
//! request-handling loop is exercised at integration time by the
//! navigation tests, so this file stays focused on conversion correctness.
use nuwiki_core::ast::{
inline::InlineNode, BlockNode, BlockquoteNode, DocumentNode, ErrorNode, ParagraphNode,
Position, Span, TextNode,
};
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::{ast_diagnostics, headings_to_symbols, to_lsp_position, to_lsp_range};
use tower_lsp::lsp_types::{DiagnosticSeverity, DocumentSymbolResponse, SymbolKind};
// ===== Position / Range conversion =====
#[test]
fn position_passthrough_in_utf8_mode() {
let pos = Position {
line: 3,
column: 7,
offset: 0,
};
let lsp = to_lsp_position(&pos, "ignored", true);
assert_eq!(lsp.line, 3);
assert_eq!(lsp.character, 7);
}
#[test]
fn position_translates_to_utf16_for_multibyte_chars() {
// "héllo" in UTF-8: h(1) é(2 bytes) l(1) l(1) o(1) — total 6 bytes.
// In UTF-16 each char is one code unit (BMP), so byte col 3 ("after é")
// maps to UTF-16 char col 2.
let line = "héllo\nworld\n";
let pos = Position {
line: 0,
column: 3,
offset: 3,
};
let lsp = to_lsp_position(&pos, line, false);
assert_eq!(lsp.line, 0);
assert_eq!(lsp.character, 2);
}
#[test]
fn position_handles_astral_plane_in_utf16() {
// 🦀 is U+1F980, two UTF-16 code units (surrogate pair), four UTF-8 bytes.
let text = "🦀x\n";
// After the crab + x, byte col is 5, UTF-16 col is 3 (2 surrogates + 1 BMP).
let pos = Position {
line: 0,
column: 5,
offset: 5,
};
let lsp = to_lsp_position(&pos, text, false);
assert_eq!(lsp.character, 3);
}
#[test]
fn range_passthrough_in_utf8_mode() {
let span = Span::new(
Position {
line: 0,
column: 0,
offset: 0,
},
Position {
line: 0,
column: 5,
offset: 5,
},
);
let r = to_lsp_range(&span, "hello\n", true);
assert_eq!(r.start.line, 0);
assert_eq!(r.start.character, 0);
assert_eq!(r.end.character, 5);
}
// ===== Diagnostics =====
#[test]
fn ast_diagnostics_yields_one_per_error_node() {
let span_a = Span::new(
Position {
line: 0,
column: 0,
offset: 0,
},
Position {
line: 0,
column: 4,
offset: 4,
},
);
let span_b = Span::new(
Position {
line: 2,
column: 0,
offset: 10,
},
Position {
line: 2,
column: 3,
offset: 13,
},
);
let doc = DocumentNode {
span: Span::default(),
metadata: Default::default(),
children: vec![
BlockNode::Error(ErrorNode {
span: span_a,
raw: "first".into(),
message: "first parse failure".into(),
}),
BlockNode::Paragraph(ParagraphNode {
span: Span::default(),
children: vec![InlineNode::Text(TextNode {
span: Span::default(),
content: "ok".into(),
})],
}),
BlockNode::Error(ErrorNode {
span: span_b,
raw: "second".into(),
message: "second parse failure".into(),
}),
],
};
let diags = ast_diagnostics(&doc, "ignored", true);
assert_eq!(diags.len(), 2);
assert_eq!(diags[0].message, "first parse failure");
assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
assert_eq!(diags[0].source.as_deref(), Some("nuwiki"));
assert_eq!(diags[0].range.start.line, 0);
assert_eq!(diags[1].range.start.line, 2);
}
#[test]
fn ast_diagnostics_descends_into_blockquotes() {
let span = Span::new(
Position {
line: 1,
column: 2,
offset: 0,
},
Position {
line: 1,
column: 6,
offset: 4,
},
);
let inner_error = BlockNode::Error(ErrorNode {
span,
raw: "x".into(),
message: "nested".into(),
});
let bq = BlockNode::Blockquote(BlockquoteNode {
span: Span::default(),
children: vec![inner_error],
});
let doc = DocumentNode {
span: Span::default(),
metadata: Default::default(),
children: vec![bq],
};
let diags = ast_diagnostics(&doc, "", true);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].message, "nested");
}
// ===== Document symbols =====
fn parse(src: &str) -> DocumentNode {
VimwikiSyntax::new().parse(src)
}
#[test]
fn flat_outline_when_all_headings_same_level() {
let doc = parse("= A =\n= B =\n= C =\n");
let syms = headings_to_symbols(&doc, "", true);
assert_eq!(syms.len(), 3);
let names: Vec<&str> = syms.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["A", "B", "C"]);
for s in &syms {
assert!(s.children.is_none() || s.children.as_ref().unwrap().is_empty());
assert_eq!(s.kind, SymbolKind::STRING);
}
}
#[test]
fn nested_outline_follows_heading_levels() {
let src = "\
= Top =
== Sub A ==
=== Deeper ===
== Sub B ==
= Other =
";
let doc = parse(src);
let syms = headings_to_symbols(&doc, "", true);
assert_eq!(syms.len(), 2);
// Top contains Sub A and Sub B; Sub A contains Deeper.
let top = &syms[0];
assert_eq!(top.name, "Top");
let top_kids = top.children.as_ref().expect("Top should have children");
assert_eq!(top_kids.len(), 2);
assert_eq!(top_kids[0].name, "Sub A");
assert_eq!(top_kids[1].name, "Sub B");
let sub_a_kids = top_kids[0]
.children
.as_ref()
.expect("Sub A should have children");
assert_eq!(sub_a_kids.len(), 1);
assert_eq!(sub_a_kids[0].name, "Deeper");
assert_eq!(syms[1].name, "Other");
}
#[test]
fn heading_title_strips_inline_markers() {
let doc = parse("= Hello *world* and `code` =\n");
let syms = headings_to_symbols(&doc, "", true);
assert_eq!(syms.len(), 1);
assert_eq!(syms[0].name, "Hello world and code");
}
#[test]
fn empty_heading_falls_back_to_placeholder_name() {
let doc = parse("= =\n");
let syms = headings_to_symbols(&doc, "", true);
if let Some(s) = syms.first() {
assert!(!s.name.is_empty());
}
}
// ===== End-to-end through the registry =====
#[test]
fn outline_symbols_match_registry_dispatch() {
let src = "= A =\n== B ==\n";
let plugin = VimwikiSyntax::new();
let doc = plugin.parse(src);
let syms = headings_to_symbols(&doc, src, true);
assert_eq!(syms.len(), 1);
assert_eq!(syms[0].name, "A");
let kids = syms[0].children.as_ref().unwrap();
assert_eq!(kids[0].name, "B");
// Sanity: symbols can be wrapped in DocumentSymbolResponse::Nested.
let _resp = DocumentSymbolResponse::Nested(syms);
}
+785
View File
@@ -0,0 +1,785 @@
//! HTML export commands.
//!
//! Drives the pure `export::*` helpers directly. The side-effecting
//! `export_ops::write_page` / `write_rss` paths get exercised via a
//! per-test temp dir so we know writes land where they should and
//! the rendered HTML contains the substituted vars.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use nuwiki_core::date::DiaryDate;
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::config::{HtmlConfig, WikiConfig};
use nuwiki_lsp::export;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn cfg(root: &str) -> WikiConfig {
WikiConfig::from_root(PathBuf::from(root))
}
fn temp_root(suffix: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("nuwiki-p17-{suffix}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
// ===== HtmlConfig defaults =====
#[test]
fn html_config_defaults_match_vimwiki() {
let c = HtmlConfig::default();
assert_eq!(c.template_default, "default");
assert_eq!(c.template_ext, ".tpl");
assert_eq!(c.template_date_format, "%Y-%m-%d");
assert_eq!(c.css_name, "style.css");
assert!(!c.auto_export);
assert!(!c.html_filename_parameterization);
assert!(c.exclude_files.is_empty());
assert_eq!(c.html_header_numbering, 0); // upstream default: numbering off
assert!(c.html_header_numbering_sym.is_empty());
}
#[test]
fn html_config_from_root_uses_underscore_dirs() {
let h = HtmlConfig::from_root(Path::new("/tmp/wiki"));
assert_eq!(h.html_path, PathBuf::from("/tmp/wiki/_html"));
assert_eq!(h.template_path, PathBuf::from("/tmp/wiki/_templates"));
}
#[test]
fn wiki_config_picks_up_html_paths() {
let c = cfg("/tmp/x");
assert_eq!(c.html.html_path, PathBuf::from("/tmp/x/_html"));
}
#[test]
fn config_from_json_honours_explicit_html_keys() {
use nuwiki_lsp::config::config_from_json;
let c = config_from_json(serde_json::json!({
"wikis": [{
"root": "/tmp/y",
"html_path": "/tmp/y/site",
"template_default": "post",
"template_ext": ".tmpl",
"auto_export": true,
"html_filename_parameterization": true,
"exclude_files": ["_drafts/**"],
}],
}));
let h = &c.wikis[0].html;
assert_eq!(h.html_path, PathBuf::from("/tmp/y/site"));
assert_eq!(h.template_default, "post");
assert_eq!(h.template_ext, ".tmpl");
assert!(h.auto_export);
assert!(h.html_filename_parameterization);
assert_eq!(h.exclude_files, vec!["_drafts/**"]);
}
// ===== format_date =====
#[test]
fn format_date_strftime_subset() {
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
assert_eq!(export::format_date(&d, "%Y-%m-%d"), "2026-05-11");
assert_eq!(export::format_date(&d, "%Y"), "2026");
assert_eq!(export::format_date(&d, "%m/%d/%Y"), "05/11/2026");
assert_eq!(export::format_date(&d, "literal text"), "literal text");
assert_eq!(
export::format_date(&d, "100%% done"),
"100% done",
"%% escapes to a literal %"
);
}
#[test]
fn format_date_unknown_specifier_passes_through() {
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
// We support Y/m/d/%; %H is not supported and should pass through verbatim.
assert_eq!(export::format_date(&d, "%H:00"), "%H:00");
}
// ===== compute_root_path =====
#[test]
fn compute_root_path_is_empty_at_top_level() {
assert_eq!(export::compute_root_path("Home"), "");
}
#[test]
fn compute_root_path_adds_one_dotdot_per_subdir() {
assert_eq!(export::compute_root_path("diary/2026-05-11"), "../");
assert_eq!(export::compute_root_path("a/b/c"), "../../");
}
// ===== output_path_for =====
#[test]
fn output_path_for_basic() {
let c = cfg("/tmp/x");
assert_eq!(
export::output_path_for(&c.html, "Home"),
PathBuf::from("/tmp/x/_html/Home.html")
);
}
#[test]
fn output_path_for_nested_preserves_subdirs() {
let c = cfg("/tmp/x");
assert_eq!(
export::output_path_for(&c.html, "diary/2026-05-11"),
PathBuf::from("/tmp/x/_html/diary/2026-05-11.html")
);
}
#[test]
fn output_path_for_slugifies_only_the_filename_when_enabled() {
let mut c = cfg("/tmp/x");
c.html.html_filename_parameterization = true;
let out = export::output_path_for(&c.html, "Notes/My Page!");
// subdir keeps casing, filename becomes lowercase + url-safe.
assert_eq!(out, PathBuf::from("/tmp/x/_html/notes/my-page.html"));
}
// ===== is_excluded =====
#[test]
fn is_excluded_matches_globstar() {
let patterns = vec!["_drafts/**".into()];
assert!(export::is_excluded(&patterns, "_drafts/foo"));
assert!(export::is_excluded(&patterns, "_drafts/x/y"));
assert!(!export::is_excluded(&patterns, "notes/x"));
}
#[test]
fn is_excluded_matches_simple_glob() {
let patterns = vec!["*.tmp".into()];
assert!(export::is_excluded(&patterns, "foo.tmp"));
assert!(!export::is_excluded(&patterns, "foo.wiki"));
}
#[test]
fn is_excluded_matches_question_mark() {
let patterns = vec!["??".into()];
assert!(export::is_excluded(&patterns, "ab"));
assert!(!export::is_excluded(&patterns, "abc"));
}
#[test]
fn is_excluded_no_match_returns_false() {
let patterns = vec!["foo".into()];
assert!(!export::is_excluded(&patterns, "bar"));
}
// ===== TOC HTML =====
#[test]
fn build_toc_html_produces_nested_lists() {
let ast = parse("= One =\n== Two ==\n=== Three ===\n= Four =\n");
let html = export::build_toc_html(&ast);
assert!(html.contains("ul class=\"toc\""));
assert!(html.contains(">One<"));
assert!(html.contains(">Two<"));
assert!(html.contains(">Three<"));
assert!(html.contains(">Four<"));
// Verify the nesting: Two should be inside an inner <ul> after One.
let one_idx = html.find(">One<").unwrap();
let two_idx = html.find(">Two<").unwrap();
let three_idx = html.find(">Three<").unwrap();
assert!(one_idx < two_idx && two_idx < three_idx);
}
#[test]
fn build_toc_html_empty_for_no_headings() {
let ast = parse("Just a paragraph.\n");
assert!(export::build_toc_html(&ast).is_empty());
}
#[test]
fn build_toc_html_escapes_special_chars_in_title() {
let ast = parse("= A & B <ok> =\n");
let html = export::build_toc_html(&ast);
assert!(html.contains("A &amp; B &lt;ok&gt;"));
}
// ===== template_for =====
#[test]
fn template_for_prefers_metadata_template() {
let c = cfg("/tmp/x");
let ast = parse("%template post\n= Hi =\n");
let src = export::template_for(&c.html, &ast);
assert_eq!(
src.primary,
Some(PathBuf::from("/tmp/x/_templates/post.tpl"))
);
assert_eq!(src.fallback, PathBuf::from("/tmp/x/_templates/default.tpl"));
}
#[test]
fn template_for_falls_back_when_no_directive() {
let c = cfg("/tmp/x");
let ast = parse("= Hi =\n");
let src = export::template_for(&c.html, &ast);
assert!(src.primary.is_none());
assert_eq!(src.fallback, PathBuf::from("/tmp/x/_templates/default.tpl"));
}
// ===== render_page_html =====
#[test]
fn render_page_html_passes_through_valid_html_tags() {
// `<sub>`/`<kbd>` are in the default valid_html_tags → verbatim; `<script>`
// is not → escaped.
let ast = parse("H<sub>2</sub>O press <kbd>Ctrl</kbd> <script>x</script>\n");
let c = cfg("/tmp/x");
let html = export::render_page_html(
&ast,
Some("{{content}}".into()),
"Home",
DiaryDate::today_utc(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains("H<sub>2</sub>O"), "sub verbatim: {html}");
assert!(html.contains("<kbd>Ctrl</kbd>"), "kbd verbatim: {html}");
assert!(html.contains("&lt;script&gt;"), "script escaped: {html}");
}
#[test]
fn render_page_html_ignore_newline_controls_soft_breaks() {
// Paragraph + list item, each with a soft (single) newline inside.
let ast = parse("alpha\nbeta\n\n- one\n two\n");
// Default (true/true): soft breaks render as spaces, no <br>.
let c = cfg("/tmp/x");
let def = export::render_page_html(
&ast,
Some("{{content}}".into()),
"Home",
DiaryDate::today_utc(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(!def.contains("<br"), "default joins with spaces: {def}");
assert!(def.contains("alpha beta"), "para space: {def}");
// false/false: soft breaks become <br /> in both paragraph and list item.
let mut c2 = cfg("/tmp/x");
c2.html.text_ignore_newline = false;
c2.html.list_ignore_newline = false;
let br = export::render_page_html(
&ast,
Some("{{content}}".into()),
"Home",
DiaryDate::today_utc(),
&c2.html,
None,
HashMap::new(),
)
.unwrap();
assert!(br.contains("alpha<br />beta"), "para <br>: {br}");
assert!(
br.contains("one<br />two") || br.contains("one<br /> two"),
"list <br>: {br}"
);
}
#[test]
fn render_page_html_substitutes_emoji_when_enabled() {
let ast = parse("ship it :rocket: and :tada:\n");
let c = cfg("/tmp/x"); // emoji_enable defaults true
let html = export::render_page_html(
&ast,
Some("{{content}}".into()),
"Home",
DiaryDate::today_utc(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains("🚀"), "rocket: {html}");
assert!(html.contains("🎉"), "tada: {html}");
assert!(!html.contains(":rocket:"), "shortcode replaced: {html}");
}
#[test]
fn render_page_html_emoji_off_leaves_shortcodes() {
let ast = parse("plain :rocket: here\n");
let mut c = cfg("/tmp/x");
c.html.emoji_enable = false;
let html = export::render_page_html(
&ast,
Some("{{content}}".into()),
"Home",
DiaryDate::today_utc(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains(":rocket:"), "left as-is: {html}");
}
#[test]
fn render_page_html_substitutes_title_content_and_extras() {
let ast = parse("%title My Page\n= One =\nbody text\n");
let c = cfg("/tmp/x");
let template = "<title>{{title}}</title><body>{{content}}</body><footer>{{date}}</footer>";
let extras = HashMap::new();
let html = export::render_page_html(
&ast,
Some(template.into()),
"Home",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
extras,
)
.unwrap();
assert!(html.contains("<title>My Page</title>"));
assert!(html.contains("<footer>2026-05-11</footer>"));
assert!(html.contains("body text"));
}
#[test]
fn render_page_html_uses_metadata_date_when_set() {
let ast = parse("%date 2025-01-02\n= Hi =\n");
let c = cfg("/tmp/x");
let html = export::render_page_html(
&ast,
Some("DATE={{date}}".into()),
"Home",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains("DATE=2025-01-02"), "got: {html}");
}
#[test]
fn render_page_html_numbers_headers_when_enabled() {
let ast = parse("= Intro =\n== Background ==\n== Methods ==\n= Results =\n");
let mut c = cfg("/tmp/x");
c.html.html_header_numbering = 1; // number from h1
c.html.html_header_numbering_sym = ".".into();
let html = export::render_page_html(
&ast,
None,
"Home",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(
html.contains("<h1 id=\"Intro\">1. Intro</h1>"),
"got: {html}"
);
assert!(
html.contains("<h2 id=\"Background\">1.1. Background</h2>"),
"got: {html}"
);
assert!(
html.contains("<h2 id=\"Methods\">1.2. Methods</h2>"),
"got: {html}"
);
assert!(
html.contains("<h1 id=\"Results\">2. Results</h1>"),
"got: {html}"
);
}
#[test]
fn render_page_html_numbering_skips_headers_above_start_level() {
let ast = parse("= Title =\n== Section ==\n=== Sub ===\n");
let mut c = cfg("/tmp/x");
c.html.html_header_numbering = 2; // start at h2; h1 stays unnumbered
// empty sym → number followed by a bare space
let html = export::render_page_html(
&ast,
None,
"Home",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(
html.contains("<h1 id=\"Title\">Title</h1>"),
"h1 unnumbered, got: {html}"
);
assert!(
html.contains("<h2 id=\"Section\">1 Section</h2>"),
"got: {html}"
);
assert!(html.contains("<h3 id=\"Sub\">1.1 Sub</h3>"), "got: {html}");
}
#[test]
fn render_page_html_no_header_numbering_by_default() {
let ast = parse("= Intro =\n== Sub ==\n");
let c = cfg("/tmp/x");
let html = export::render_page_html(
&ast,
None,
"Home",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains("<h1 id=\"Intro\">Intro</h1>"), "got: {html}");
assert!(html.contains("<h2 id=\"Sub\">Sub</h2>"), "got: {html}");
}
#[test]
fn render_page_html_root_path_for_nested_pages() {
let ast = parse("= D =\n");
let c = cfg("/tmp/x");
let html = export::render_page_html(
&ast,
Some("CSS={{root_path}}{{css}}".into()),
"diary/2026-05-11",
DiaryDate::today_utc(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains("CSS=../style.css"), "got: {html}");
}
#[test]
fn render_page_html_extra_var_overrides_default() {
let ast = parse("= H =\n");
let c = cfg("/tmp/x");
let mut extras = HashMap::new();
extras.insert("date".into(), "OVERRIDE".into());
let html = export::render_page_html(
&ast,
Some("D={{date}}".into()),
"H",
DiaryDate::today_utc(),
&c.html,
None,
extras,
)
.unwrap();
assert!(html.contains("D=OVERRIDE"));
}
#[test]
fn render_page_html_substitutes_vimwiki_percent_template() {
// A stock vimwiki template (%title%/%content%/%root_path%/%wiki_css%/%date%)
// must render fully — every placeholder resolved, no literal `%…%` left.
let ast = parse("%title My Page\n= One =\nbody text\n");
let c = cfg("/tmp/x");
let template = "<title>%title%</title>\
<link href=\"%root_path%%wiki_css%\">\
<body>%content%</body><footer>%date%</footer>";
let html = export::render_page_html(
&ast,
Some(template.into()),
"diary/2026-05-11",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains("<title>My Page</title>"), "title: {html}");
assert!(html.contains("body text"), "content: {html}");
assert!(
html.contains("href=\"../style.css\""),
"root_path+css: {html}"
);
assert!(html.contains("<footer>2026-05-11</footer>"), "date: {html}");
assert!(!html.contains('%'), "placeholder left behind: {html}");
}
#[test]
fn render_page_html_strips_wiki_extension_from_links() {
// A link written with the extension (`[[todo.wiki]]`) must export to
// `todo.html`, not `todo.wiki.html` — matching in-editor navigation.
let ast = parse("[[todo.wiki|Tasks]] and [[posts/index|Posts]]\n");
let c = cfg("/tmp/x");
let html = export::render_page_html(
&ast,
Some("{{content}}".into()),
"index",
DiaryDate::today_utc(),
&c.html,
Some(".wiki"),
HashMap::new(),
)
.unwrap();
assert!(html.contains("href=\"todo.html\""), "stripped: {html}");
assert!(!html.contains("todo.wiki.html"), "not double-ext: {html}");
// A link without the extension is unaffected.
assert!(html.contains("href=\"posts/index.html\""), "plain: {html}");
}
// ===== fallback_template + DEFAULT_CSS =====
#[test]
fn fallback_template_references_root_path_and_css() {
let t = export::fallback_template("custom.css");
assert!(t.contains("{{title}}"));
assert!(t.contains("{{content}}"));
assert!(t.contains("{{root_path}}custom.css"));
}
#[test]
fn default_css_is_non_empty() {
assert!(!export::DEFAULT_CSS.is_empty());
assert!(export::DEFAULT_CSS.contains("font-family"));
}
// ===== write_page (disk-touching) =====
#[test]
fn write_page_writes_html_to_disk() {
let root = temp_root("write");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
let ast = parse("= Hello =\nworld\n");
let outcome = nuwiki_lsp::commands::export_ops::write_page(&ast, "Hello", &c, false).unwrap();
assert_eq!(outcome.page, "Hello");
assert!(outcome.output_path.exists());
let body = std::fs::read_to_string(&outcome.output_path).unwrap();
assert!(body.contains("Hello"));
}
#[test]
fn write_page_creates_subdirs() {
let root = temp_root("subdir");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
let ast = parse("= Daily =\n");
let outcome =
nuwiki_lsp::commands::export_ops::write_page(&ast, "diary/2026-05-11", &c, false).unwrap();
assert!(outcome.output_path.exists());
assert!(outcome
.output_path
.to_string_lossy()
.contains("diary/2026-05-11.html"));
}
#[test]
fn write_page_uses_template_file_when_present() {
let root = temp_root("template");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
// Write a template file the renderer should pick up.
std::fs::create_dir_all(&c.html.template_path).unwrap();
std::fs::write(
c.html.template_path.join("default.tpl"),
"<x>{{title}}|{{content}}</x>",
)
.unwrap();
let ast = parse("%title T\n= H =\nbody\n");
let outcome = nuwiki_lsp::commands::export_ops::write_page(&ast, "P", &c, false).unwrap();
let body = std::fs::read_to_string(outcome.output_path).unwrap();
assert!(body.starts_with("<x>T|"));
assert!(body.ends_with("</x>"));
}
#[test]
fn ensure_css_writes_default_when_missing() {
let root = temp_root("css");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
let res = nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap();
assert!(res.created);
assert!(res.path.exists());
}
#[test]
fn ensure_css_is_idempotent_when_present() {
let root = temp_root("css2");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap();
let res = nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap();
assert!(!res.created);
}
// ===== RSS =====
#[test]
fn write_rss_emits_valid_xml_with_entries() {
use nuwiki_lsp::diary::DiaryEntry;
use tower_lsp::lsp_types::Url;
let root = temp_root("rss");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
c.name = "Test Wiki".into();
let entries = vec![
DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
uri: Url::parse("file:///tmp/diary/2026-05-11.wiki").unwrap(),
},
DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, 10).unwrap(),
uri: Url::parse("file:///tmp/diary/2026-05-10.wiki").unwrap(),
},
];
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
let xml = std::fs::read_to_string(&path).unwrap();
assert!(xml.starts_with("<?xml"));
assert!(xml.contains("<title>Test Wiki</title>"));
// Newest first.
let i11 = xml.find("2026-05-11").unwrap();
let i10 = xml.find("2026-05-10").unwrap();
assert!(i11 < i10);
// No base_url → item links keep the file:// URI.
assert!(xml.contains("<link>file:///tmp/diary/2026-05-11.wiki</link>"));
}
#[test]
fn write_rss_uses_base_url_for_public_links() {
use nuwiki_lsp::diary::DiaryEntry;
use tower_lsp::lsp_types::Url;
let root = temp_root("rss_base");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
c.html.base_url = "https://example.com/wiki/".into();
c.diary_rel_path = "diary".into();
let entries = vec![DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
uri: Url::parse("file:///tmp/diary/2026-05-11.wiki").unwrap(),
}];
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
let xml = std::fs::read_to_string(&path).unwrap();
// Channel link points at the diary index page (upstream fidelity); item
// link is the public per-entry URL.
assert!(
xml.contains("<link>https://example.com/wiki/diary/diary.html</link>"),
"channel link: {xml}"
);
assert!(xml.contains("<link>https://example.com/wiki/diary/2026-05-11.html</link>"));
// guid is the bare date, isPermaLink=false.
assert!(
xml.contains("<guid isPermaLink=\"false\">2026-05-11</guid>"),
"guid: {xml}"
);
// atom:link self + a pubDate are present.
assert!(xml.contains("rel=\"self\""), "atom:link: {xml}");
assert!(xml.contains("<pubDate>"), "pubDate: {xml}");
assert!(!xml.contains("file:///tmp/diary"));
}
#[test]
fn prune_orphan_html_deletes_sourceless_keeps_protected() {
let root = temp_root("prune");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
c.html.user_htmls = vec!["keep.html".into()];
let html = &c.html.html_path;
std::fs::create_dir_all(html).unwrap();
// A sourced page (Home.wiki exists) → kept.
std::fs::write(root.join("Home.wiki"), "= Home =\n").unwrap();
std::fs::write(html.join("Home.html"), "<h1>Home</h1>").unwrap();
// An orphan (no source) → pruned.
std::fs::write(html.join("Ghost.html"), "<h1>Ghost</h1>").unwrap();
// A user_htmls-protected orphan → kept.
std::fs::write(html.join("keep.html"), "<p>manual</p>").unwrap();
// The CSS file → never pruned.
std::fs::write(html.join(&c.html.css_name), "body{}").unwrap();
let pruned = nuwiki_lsp::commands::export_ops::prune_orphan_html(&c);
assert_eq!(pruned.len(), 1, "only the orphan: {pruned:?}");
assert!(!html.join("Ghost.html").exists(), "orphan deleted");
assert!(html.join("Home.html").exists(), "sourced kept");
assert!(html.join("keep.html").exists(), "user_htmls kept");
assert!(html.join(&c.html.css_name).exists(), "css kept");
}
#[test]
fn write_rss_honours_rss_name_and_max_items() {
use nuwiki_lsp::diary::DiaryEntry;
use tower_lsp::lsp_types::Url;
let root = temp_root("rss_cap");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
c.html.rss_name = "feed.xml".into();
c.html.rss_max_items = 2;
let entries: Vec<DiaryEntry> = (1..=5)
.map(|d| DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, d).unwrap(),
uri: Url::parse(&format!("file:///tmp/diary/2026-05-0{d}.wiki")).unwrap(),
})
.collect();
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
assert!(path.ends_with("feed.xml"), "rss_name: {path:?}");
let xml = std::fs::read_to_string(&path).unwrap();
// Capped at 2 items (newest first: 05-05, 05-04).
assert_eq!(xml.matches("<item>").count(), 2, "cap: {xml}");
assert!(xml.contains("2026-05-05") && xml.contains("2026-05-04"));
assert!(!xml.contains("2026-05-03"), "older items dropped: {xml}");
}
#[test]
fn write_page_invokes_custom_wiki2html_converter() {
let root = temp_root("custom_w2h");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
// Source file must exist on disk — the converter receives its path.
std::fs::write(root.join("Hello.wiki"), "= Hello =\nworld\n").unwrap();
// A tiny shell "converter": writes a marker into the output path it is told
// to produce. nuwiki appends args positionally after `_` ($0), so
// $1=force, $2=syntax, $3=ext, $4=output_dir, $5=input_file, …
let script = "mkdir -p \"$4\" && printf 'CUSTOM:%s' \"$5\" > \"$4\"/Hello.html";
c.html.custom_wiki2html = format!("sh -c {} _", shell_words_quote(script));
let outcome =
nuwiki_lsp::commands::export_ops::write_page(&parse("= Hello =\n"), "Hello", &c, true)
.unwrap();
assert_eq!(outcome.page, "Hello");
assert!(
outcome.output_path.exists(),
"converter must produce output"
);
let body = std::fs::read_to_string(&outcome.output_path).unwrap();
assert!(
body.starts_with("CUSTOM:"),
"marker from converter, got {body}"
);
}
/// Minimal single-quote shell escaping for the test command above.
fn shell_words_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
// ===== COMMANDS list completeness =====
#[test]
fn commands_list_includes_export_entries() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [
"nuwiki.export.currentToHtml",
"nuwiki.export.allToHtml",
"nuwiki.export.allToHtmlForce",
"nuwiki.export.browse",
"nuwiki.export.rss",
] {
assert!(names.contains(&name), "missing: {name}");
}
}
+693
View File
@@ -0,0 +1,693 @@
//! 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 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"));
}
#[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");
// 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");
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, "");
// 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, "");
// 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>");
}
#[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",
"diary_months": ["Jan","Feb","Mar","Apr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"],
"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/",
"html_header_numbering": 2,
"html_header_numbering_sym": ".",
}],
}));
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.diary_months[4], "Mai");
assert_eq!(w.diary_months[11], "Dez");
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/");
assert_eq!(w.html.html_header_numbering, 2);
assert_eq!(w.html.html_header_numbering_sym, ".");
// 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 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);
}
#[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");
}
#[test]
fn vimwiki_compat_payload_sets_toc_level_per_wiki() {
// The exact shape the Vim client emits when translating g:vimwiki_list +
// g:vimwiki_toc_header_level=2 (drop-in vimwiki compat).
let cfg = config_from_json(json!({
"wiki_root": "~/vimwiki",
"wikis": [
{ "root": "/tmp/personal", "toc_header_level": 2, "html_header_numbering": 2 },
{ "root": "/tmp/ifood", "toc_header_level": 2 },
],
}));
assert_eq!(cfg.wikis.len(), 2);
assert_eq!(cfg.wikis[0].toc_header_level, 2);
assert_eq!(cfg.wikis[0].html.html_header_numbering, 2);
assert_eq!(cfg.wikis[1].toc_header_level, 2);
}
+898
View File
@@ -0,0 +1,898 @@
//! Link-health diagnostics + TOC/links/orphans queries.
use std::path::PathBuf;
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::commands::ops;
use nuwiki_lsp::config::LinkSeverity;
use nuwiki_lsp::diagnostics::{self, BrokenLinkKind};
use nuwiki_lsp::index::WorkspaceIndex;
use tower_lsp::lsp_types::{DiagnosticSeverity, Url};
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
for (name, src) in pages {
let ast = parse(src);
let path = format!("{root}/{name}.wiki");
let uri = Url::from_file_path(&path).unwrap();
idx.upsert(uri, &ast);
}
idx
}
fn home_uri(root: &str) -> Url {
Url::from_file_path(format!("{root}/Home.wiki")).unwrap()
}
// ===== severity_to_lsp =====
#[test]
fn severity_off_returns_none() {
assert!(diagnostics::severity_to_lsp(LinkSeverity::Off).is_none());
}
#[test]
fn severity_levels_round_trip() {
assert_eq!(
diagnostics::severity_to_lsp(LinkSeverity::Hint),
Some(DiagnosticSeverity::HINT)
);
assert_eq!(
diagnostics::severity_to_lsp(LinkSeverity::Warning),
Some(DiagnosticSeverity::WARNING)
);
assert_eq!(
diagnostics::severity_to_lsp(LinkSeverity::Error),
Some(DiagnosticSeverity::ERROR)
);
}
// ===== link_health: wiki targets =====
#[test]
fn link_to_missing_page_warns() {
let root = "/tmp/lh1";
let idx = build_index(root, &[("Home", "[[GhostPage]]\n")]);
let src = "[[GhostPage]]\n";
let ast = parse(src);
let uri = home_uri(root);
let diags = diagnostics::link_health(
&ast,
src,
Some(&uri),
&idx,
"Home",
LinkSeverity::Warning,
true,
);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("GhostPage"));
assert_eq!(diags[0].severity, Some(DiagnosticSeverity::WARNING));
}
#[test]
fn link_to_existing_page_silent() {
let root = "/tmp/lh2";
let idx = build_index(root, &[("Home", "[[Other]]\n"), ("Other", "= Other =\n")]);
let src = "[[Other]]\n";
let ast = parse(src);
let diags = diagnostics::link_health(
&ast,
src,
Some(&home_uri(root)),
&idx,
"Home",
LinkSeverity::Warning,
true,
);
assert!(diags.is_empty(), "got: {:?}", diags);
}
#[test]
fn link_with_valid_anchor_silent() {
let root = "/tmp/lh3";
let idx = build_index(
root,
&[
("Home", "[[Target#section-one]]\n"),
("Target", "= Target =\n== Section One ==\n"),
],
);
let src = "[[Target#section-one]]\n";
let ast = parse(src);
let diags = diagnostics::link_health(
&ast,
src,
Some(&home_uri(root)),
&idx,
"Home",
LinkSeverity::Warning,
true,
);
assert!(diags.is_empty(), "got: {:?}", diags);
}
#[test]
fn link_with_missing_anchor_warns() {
let root = "/tmp/lh4";
let idx = build_index(
root,
&[
("Home", "[[Target#ghost-anchor]]\n"),
("Target", "= Target =\n"),
],
);
let src = "[[Target#ghost-anchor]]\n";
let ast = parse(src);
let diags = diagnostics::link_health(
&ast,
src,
Some(&home_uri(root)),
&idx,
"Home",
LinkSeverity::Warning,
true,
);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.to_lowercase().contains("anchor"));
}
#[test]
fn anchor_only_link_resolves_against_current_page() {
let root = "/tmp/lh5";
let idx = build_index(root, &[("Home", "= Home =\n== Intro ==\n[[#intro]]\n")]);
let src = "= Home =\n== Intro ==\n[[#intro]]\n";
let ast = parse(src);
let diags = diagnostics::link_health(
&ast,
src,
Some(&home_uri(root)),
&idx,
"Home",
LinkSeverity::Warning,
true,
);
assert!(diags.is_empty(), "got: {:?}", diags);
}
#[test]
fn anchor_only_link_to_missing_anchor_warns() {
let root = "/tmp/lh6";
let idx = build_index(root, &[("Home", "[[#nowhere]]\n")]);
let src = "[[#nowhere]]\n";
let ast = parse(src);
let diags = diagnostics::link_health(
&ast,
src,
Some(&home_uri(root)),
&idx,
"Home",
LinkSeverity::Warning,
true,
);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("nowhere"));
}
#[test]
fn link_severity_off_emits_nothing() {
let root = "/tmp/lh7";
let idx = build_index(root, &[("Home", "[[GhostPage]]\n")]);
let src = "[[GhostPage]]\n";
let ast = parse(src);
let diags = diagnostics::link_health(
&ast,
src,
Some(&home_uri(root)),
&idx,
"Home",
LinkSeverity::Off,
true,
);
assert!(diags.is_empty());
}
#[test]
fn raw_url_never_emits_diagnostic() {
let root = "/tmp/lh8";
let idx = build_index(root, &[("Home", "https://example.com\n")]);
let src = "https://example.com\n";
let ast = parse(src);
let diags = diagnostics::link_health(
&ast,
src,
Some(&home_uri(root)),
&idx,
"Home",
LinkSeverity::Error,
true,
);
assert!(diags.is_empty());
}
#[test]
fn tag_as_anchor_resolves() {
let root = "/tmp/lh9";
let idx = build_index(
root,
&[
("Home", "[[Notes#release]]\n"),
("Notes", "= Notes =\n:release:\n"),
],
);
let src = "[[Notes#release]]\n";
let ast = parse(src);
let diags = diagnostics::link_health(
&ast,
src,
Some(&home_uri(root)),
&idx,
"Home",
LinkSeverity::Warning,
true,
);
assert!(diags.is_empty(), "got: {:?}", diags);
}
// ===== BrokenLinkKind classification =====
#[test]
fn classify_missing_page() {
let kind = BrokenLinkKind::MissingPage("X".into());
assert_eq!(kind.tag(), "missing-page");
assert!(kind.message().contains("X"));
}
#[test]
fn classify_missing_anchor() {
let kind = BrokenLinkKind::MissingAnchor {
page: "P".into(),
anchor: "a".into(),
};
assert_eq!(kind.tag(), "missing-anchor");
assert!(kind.message().contains("a"));
assert!(kind.message().contains("P"));
}
#[test]
fn classify_missing_file() {
let kind = BrokenLinkKind::MissingFile(PathBuf::from("/tmp/nope.txt"));
assert_eq!(kind.tag(), "missing-file");
assert!(kind.message().contains("nope.txt"));
}
// ===== heading_text =====
#[test]
fn heading_text_strips_formatting() {
let src = "= *Bold* heading =\n";
let ast = parse(src);
let h = match &ast.children[0] {
nuwiki_core::ast::BlockNode::Heading(h) => h,
_ => panic!("expected heading"),
};
let t = diagnostics::heading_text(&h.children);
assert!(t.contains("Bold"));
assert!(t.contains("heading"));
}
// ===== TOC text generation =====
#[test]
fn build_toc_text_nests_by_level() {
let items = vec![
(1u8, "Top".into(), "top".into()),
(2u8, "Sub".into(), "sub".into()),
(1u8, "Other".into(), "other".into()),
];
let out = ops::build_toc_text(&items, "Contents", 1, 0, 0);
assert!(out.starts_with("= Contents =\n"));
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "= Contents =");
assert_eq!(lines[1], "- [[#top|Top]]");
assert_eq!(lines[2], " - [[#sub|Sub]]");
assert_eq!(lines[3], "- [[#other|Other]]");
}
#[test]
fn build_toc_text_with_no_headings_is_just_the_heading() {
let out = ops::build_toc_text(&[], "Contents", 1, 0, 0);
assert_eq!(out, "= Contents =\n");
}
#[test]
fn toc_link_format_1_drops_the_description() {
// vimwiki toc_link_format: 1 = `[[#anchor]]` (no `|title`); 0 = with title.
let items = vec![(1u8, "Top".into(), "top".into())];
let f0 = ops::build_toc_text(&items, "Contents", 1, 0, 0);
assert!(f0.contains("- [[#top|Top]]"), "format 0: {f0}");
let f1 = ops::build_toc_text(&items, "Contents", 1, 0, 1);
assert!(f1.contains("- [[#top]]"), "format 1: {f1}");
assert!(!f1.contains("|Top"), "format 1 has no description: {f1}");
}
#[test]
fn generated_links_caption_emits_first_heading() {
use std::collections::BTreeMap;
let pages = vec!["About".to_string(), "Notes".to_string()];
let mut caps = BTreeMap::new();
caps.insert("About".to_string(), "About Us".to_string());
// Notes has no caption → falls back to bare [[Notes]].
let out = ops::build_links_text(&pages, "Generated Links", 1, None, 0, Some(&caps));
assert!(out.contains("- [[About|About Us]]"), "captioned: {out}");
assert!(out.contains("- [[Notes]]"), "fallback: {out}");
// Without the caption map, both are bare.
let bare = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None);
assert!(
bare.contains("- [[About]]") && !bare.contains("About Us"),
"bare: {bare}"
);
}
#[test]
fn links_rebuild_edit_only_acts_when_section_present() {
let pages = vec!["Home".to_string(), "About".to_string()];
let uri = Url::from_file_path("/w/Home.wiki").unwrap();
let without = "= Home =\nbody\n";
assert!(
ops::links_rebuild_edit(
without,
&parse(without),
&uri,
"Home",
&pages,
true,
"Generated Links",
1,
0,
None
)
.is_none(),
"must not insert a links section into a page that lacks one"
);
let with = "= Generated Links =\n- [[old]]\n";
assert!(ops::links_rebuild_edit(
with,
&parse(with),
&uri,
"Home",
&pages,
true,
"Generated Links",
1,
0,
None
)
.is_some());
}
#[test]
fn find_section_range_stops_at_same_level_sibling() {
// A non-level-1 generated section (e.g. a level-2 tags index) must not
// swallow the following same-level section when regenerated.
let src = "== Foo ==\n- a\n== Bar ==\n- b\n";
let ast = parse(src);
let (start, end) = ops::find_section_range(&ast, "Foo").expect("section found");
let bar_off = src.find("== Bar ==").unwrap();
assert_eq!(start.offset, 0);
assert!(
end.offset <= bar_off,
"section absorbed the sibling: end={} bar={}",
end.offset,
bar_off
);
}
#[test]
fn captions_honour_custom_header_and_level() {
// toc_header="Table of Contents", level 2 → `== Table of Contents ==`.
let out = ops::build_toc_text(&[], "Table of Contents", 2, 0, 0);
assert_eq!(out, "== Table of Contents ==\n");
// links_header at level 3.
let pages = vec!["A".to_string(), "B".to_string()];
let links = ops::build_links_text(&pages, "All Pages", 3, None, 0, None);
assert!(links.starts_with("=== All Pages ===\n"));
// level clamps to 1..=6.
assert!(ops::build_toc_text(&[], "X", 9, 0, 0).starts_with("====== X ======"));
assert!(ops::build_toc_text(&[], "X", 0, 0, 0).starts_with("= X ="));
}
#[test]
fn list_margin_indents_generated_bullets_not_headings() {
// vimwiki `list_margin` = leading spaces before each generated bullet.
// The heading stays at column 0; bullets get `margin` spaces (TOC keeps
// its per-depth nesting *after* the base margin).
let pages = vec!["A".to_string(), "B".to_string()];
let links = ops::build_links_text(&pages, "Generated Links", 1, None, 2, None);
assert!(links.contains("\n - [[A]]\n"), "2-space margin: {links:?}");
assert!(
links.starts_with("= Generated Links =\n"),
"heading unindented: {links:?}"
);
let toc = ops::build_toc_text(
&[
(1, "Top".into(), "top".into()),
(2, "Sub".into(), "sub".into()),
],
"Contents",
1,
2,
0,
);
// Top bullet: 2-space margin; Sub bullet: margin + one nesting level.
assert!(toc.contains("\n - [[#top|Top]]\n"), "top margin: {toc:?}");
assert!(toc.contains("\n - [[#sub|Sub]]\n"), "nested: {toc:?}");
}
// ===== Links text generation =====
#[test]
fn build_links_text_excludes_current_page() {
let pages = vec!["A".to_string(), "Home".to_string(), "B".to_string()];
let out = ops::build_links_text(&pages, "Generated Links", 1, Some("Home"), 0, None);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "= Generated Links =");
assert!(lines.contains(&"- [[A]]"));
assert!(lines.contains(&"- [[B]]"));
assert!(!lines.iter().any(|l| l.contains("Home")));
}
#[test]
fn build_links_text_no_excludes_includes_all() {
let pages = vec!["A".to_string(), "B".to_string()];
let out = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None);
assert!(out.contains("[[A]]"));
assert!(out.contains("[[B]]"));
}
// ===== find_section_range =====
#[test]
fn find_section_range_matches_case_insensitive() {
let src = "= contents =\n- [[A]]\n- [[B]]\n";
let ast = parse(src);
let (_, _) = ops::find_section_range(&ast, "Contents").expect("found");
}
#[test]
fn find_section_range_misses_when_absent() {
let src = "= Welcome =\n";
let ast = parse(src);
assert!(ops::find_section_range(&ast, "Contents").is_none());
}
#[test]
fn find_section_range_matches_any_heading_level() {
// Section replacement accepts any heading level so
// VimwikiGenerateTagLinks stays idempotent when tag sections live
// under non-h1 headings (e.g. `== Tag: foo ==`).
let src = "== Contents ==\n- [[A]]\n";
let ast = parse(src);
assert!(ops::find_section_range(&ast, "Contents").is_some());
}
// ===== toc_edit =====
#[test]
fn toc_edit_inserts_at_top_when_no_existing_toc() {
let src = "= One =\n== Two ==\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let edit =
ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).expect("got an edit");
let changes = edit.changes.expect("changes map");
let edits = &changes[&uri];
assert_eq!(edits.len(), 1);
let te = &edits[0];
// Insertion at position 0,0 → zero-width range.
assert_eq!(te.range.start, te.range.end);
assert!(te.new_text.starts_with("= Contents =\n"));
// Anchor is the raw heading text (vimwiki scheme), matching the heading id
// the HTML renderer emits on export.
assert!(te.new_text.contains("[[#One|One]]"));
}
#[test]
fn toc_edit_inserts_at_cursor_line() {
// A fresh TOC goes at the cursor line (0-based) the client sends.
let src = "= One =\nbody text\n== Two ==\nmore\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
// Cursor on line 1 ("body text").
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, Some(1)).expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
// Zero-width insert at line 1, column 0.
assert_eq!(te.range.start, te.range.end);
assert_eq!(te.range.start.line, 1);
assert_eq!(te.range.start.character, 0);
// A leading blank separates it from the preceding text.
assert!(
te.new_text.starts_with("\n= Contents ="),
"got: {:?}",
te.new_text
);
}
#[test]
fn toc_edit_cursor_line_clamped_and_existing_replaced_in_place() {
// An existing TOC is replaced in place regardless of the cursor line.
let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, Some(99)).expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
// Replacement starts at line 0 (the existing section), not the cursor.
assert_eq!(te.range.start.line, 0);
assert!(te.new_text.contains("[[#Real|Real]]"));
}
#[test]
fn toc_edit_replaces_existing_toc() {
let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let edit =
ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).expect("got an edit");
let changes = edit.changes.expect("changes map");
let edits = &changes[&uri];
let te = &edits[0];
assert!(te.new_text.contains("[[#Real|Real]]"));
assert!(!te.new_text.contains("Stale"));
// The replacement range must start at line 0.
assert_eq!(te.range.start.line, 0);
}
#[test]
fn toc_edit_returns_none_for_empty_doc() {
let src = "";
let ast = parse(src);
let uri = Url::parse("file:///tmp/empty.wiki").unwrap();
assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).is_none());
}
#[test]
fn toc_rebuild_edit_only_acts_when_toc_already_present() {
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
// No existing TOC → rebuild-on-save must not insert one.
let without = "= One =\n== Two ==\n";
let ast = parse(without);
assert!(
ops::toc_rebuild_edit(without, &ast, &uri, true, "Contents", 1, 0, 0).is_none(),
"auto_toc should not insert a TOC where none existed"
);
// Existing TOC → rebuild refreshes it.
let with = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
let ast = parse(with);
let edit =
ops::toc_rebuild_edit(with, &ast, &uri, true, "Contents", 1, 0, 0).expect("rebuild edit");
let te = &edit.changes.unwrap()[&uri][0];
assert!(te.new_text.contains("[[#Real|Real]]"));
assert!(!te.new_text.contains("Stale"));
}
// ===== links_edit =====
#[test]
fn links_edit_inserts_when_section_absent() {
let src = "Hello\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let pages = vec!["A".into(), "B".into(), "Home".into()];
let edit = ops::links_edit(
src,
&ast,
&uri,
"Home",
&pages,
true,
"Generated Links",
1,
0,
None,
None,
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
assert!(te.new_text.contains("[[A]]"));
assert!(te.new_text.contains("[[B]]"));
assert!(!te.new_text.contains("[[Home]]"));
}
#[test]
fn links_edit_inserts_at_cursor_line() {
// A fresh Generated Links section goes at the cursor line, not the EOF.
let src = "= Home =\nbody\nmore\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let pages = vec!["A".into()];
let edit = ops::links_edit(
src,
&ast,
&uri,
"Home",
&pages,
true,
"Generated Links",
1,
0,
None,
Some(1),
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
assert_eq!(te.range.start.line, 1);
assert_eq!(te.range.start.character, 0);
assert!(
te.new_text.starts_with("\n= Generated Links ="),
"got: {:?}",
te.new_text
);
}
#[test]
fn links_edit_replaces_when_section_present() {
let src = "= Generated Links =\n- [[Stale]]\n\n= Real =\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let pages = vec!["Fresh".into()];
let edit = ops::links_edit(
src,
&ast,
&uri,
"Home",
&pages,
true,
"Generated Links",
1,
0,
None,
None,
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
assert!(te.new_text.contains("[[Fresh]]"));
assert!(!te.new_text.contains("Stale"));
assert_eq!(te.range.start.line, 0);
}
#[test]
fn links_edit_returns_none_for_empty_page_list() {
let src = "Hi\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
assert!(ops::links_edit(
src,
&ast,
&uri,
"Home",
&[],
true,
"Generated Links",
1,
0,
None,
None
)
.is_none());
}
// ===== find_orphans =====
#[test]
fn find_orphans_lists_pages_with_no_backlinks() {
let root = "/tmp/orph";
let idx = build_index(
root,
&[
("Home", "[[A]]\n"),
("A", "= A =\n"),
("B", "= B =\n"), // not linked from anywhere
],
);
let orphans = ops::find_orphans(&idx);
let names: Vec<&str> = orphans.iter().map(|o| o.name.as_str()).collect();
// Home + B have no backlinks. A is linked from Home.
assert!(names.contains(&"Home"));
assert!(names.contains(&"B"));
assert!(!names.contains(&"A"));
}
// ===== collect_wiki_links (AST walker) =====
#[test]
fn collect_wiki_links_finds_nested() {
let src = "= [[A]] =\n*[[B]]* and [[C|desc]]\n- [[D]]\n";
let ast = parse(src);
let links = nuwiki_lsp::diagnostics::collect_wiki_links(&ast);
let paths: Vec<&str> = links
.iter()
.filter_map(|l| l.target.path.as_deref())
.collect();
for expected in ["A", "B", "C", "D"] {
assert!(paths.contains(&expected), "missing {expected}");
}
}
// ===== Source-relative wiki links (vimwiki default) =====
#[test]
fn wiki_link_resolves_source_relative_first() {
let root = "/tmp/srcrel1";
let idx = build_index(
root,
&[
("tips/index", "[[llm-wiki-pattern|LLM]]\n"),
("tips/llm-wiki-pattern", "= LLM =\n"),
],
);
let src = "[[llm-wiki-pattern|LLM]]\n";
let ast = parse(src);
let diags = diagnostics::link_health(
&ast,
src,
Some(&Url::from_file_path(format!("{root}/tips/index.wiki")).unwrap()),
&idx,
"tips/index",
LinkSeverity::Warning,
true,
);
assert!(
diags.is_empty(),
"expected no diagnostics, got: {:?}",
diags
);
}
#[test]
fn wiki_link_falls_back_to_root_relative() {
// A source-relative miss should fall through to root-relative — this
// keeps `[[posts/foo]]` from `index.wiki` working even though there's
// no `posts/posts/foo`.
let root = "/tmp/srcrel2";
let idx = build_index(
root,
&[("index", "[[posts/foo|Foo]]\n"), ("posts/foo", "= Foo =\n")],
);
let src = "[[posts/foo|Foo]]\n";
let ast = parse(src);
let diags = diagnostics::link_health(
&ast,
src,
Some(&home_uri(root)),
&idx,
"index",
LinkSeverity::Warning,
true,
);
assert!(diags.is_empty(), "got: {:?}", diags);
}
#[test]
fn wiki_link_absolute_skips_source_relative() {
// `[[/Page]]` is explicitly root-relative even from a subdir.
let root = "/tmp/srcrel3";
let idx = build_index(
root,
&[
("tips/index", "[[/RootPage]]\n"),
("RootPage", "= Root =\n"),
// Intentionally also create tips/RootPage so a source-relative
// resolution would (wrongly) prefer it — the absolute form must
// ignore source-relative.
("tips/RootPage", "= Tips Root =\n"),
],
);
let target = idx
.resolve_wiki_path("RootPage", "tips/index", true)
.expect("absolute link should resolve");
assert!(
target.as_str().ends_with("RootPage.wiki"),
"expected root RootPage, got {target}",
);
assert!(
!target.as_str().contains("/tips/RootPage"),
"absolute link should skip tips/RootPage, got {target}",
);
}
#[test]
fn wiki_link_parent_dir_collapses() {
// `[[../Other]]` from `posts/foo` resolves to `Other` at the wiki root.
let root = "/tmp/srcrel4";
let idx = build_index(
root,
&[
("posts/foo", "[[../Other|Other]]\n"),
("Other", "= Other =\n"),
],
);
let src = "[[../Other|Other]]\n";
let ast = parse(src);
let diags = diagnostics::link_health(
&ast,
src,
Some(&Url::from_file_path(format!("{root}/posts/foo.wiki")).unwrap()),
&idx,
"posts/foo",
LinkSeverity::Warning,
true,
);
assert!(diags.is_empty(), "got: {:?}", diags);
}
// ===== Anchor matching against raw heading text =====
#[test]
fn anchor_matches_raw_heading_text() {
// Users write `[[Page#Some Heading]]` (raw text), the index keys by
// slugify form. Slugifying the request before comparison makes both
// shapes work.
let root = "/tmp/anchor1";
let idx = build_index(
root,
&[
("Home", "[[Target#Some Heading]]\n"),
("Target", "= Target =\n== Some Heading ==\n"),
],
);
let src = "[[Target#Some Heading]]\n";
let ast = parse(src);
let diags = diagnostics::link_health(
&ast,
src,
Some(&home_uri(root)),
&idx,
"Home",
LinkSeverity::Warning,
true,
);
assert!(diags.is_empty(), "got: {:?}", diags);
}
#[test]
fn anchor_matches_unicode_heading() {
// Real-world content: heading with em dash and accented chars.
let root = "/tmp/anchor2";
let idx = build_index(
root,
&[
("Home", "[[Target#Homelab — VLANs]]\n"),
("Target", "= Target =\n== Homelab — VLANs ==\n"),
],
);
let src = "[[Target#Homelab — VLANs]]\n";
let ast = parse(src);
let diags = diagnostics::link_health(
&ast,
src,
Some(&home_uri(root)),
&idx,
"Home",
LinkSeverity::Warning,
true,
);
assert!(diags.is_empty(), "got: {:?}", diags);
}
// ===== COMMANDS completeness =====
#[test]
fn commands_list_includes_link_health_entries() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [
"nuwiki.toc.generate",
"nuwiki.links.generate",
"nuwiki.workspace.checkLinks",
"nuwiki.workspace.findOrphans",
] {
assert!(names.contains(&name), "missing: {name}");
}
}
+220
View File
@@ -0,0 +1,220 @@
//! Multi-wiki — interwiki resolution + picker commands.
//!
//! The cross-wiki resolver methods on `Backend` are exercised indirectly
//! through `tower_lsp::LspService`'s test harness via a synthetic
//! `Backend`. To keep this test suite
//! lightweight we drive the *pure* resolution primitives — the parser
//! producing `LinkKind::Interwiki` targets, then the `WorkspaceIndex`
//! lookup-by-name — and verify the multi-wiki config shape end-to-end.
use std::path::PathBuf;
use nuwiki_core::ast::{InlineNode, LinkKind};
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::config::config_from_json;
use nuwiki_lsp::index::WorkspaceIndex;
use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki, WikiId};
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn first_link(src: &str) -> nuwiki_core::ast::WikiLinkNode {
let doc = parse(src);
for block in &doc.children {
if let nuwiki_core::ast::BlockNode::Paragraph(p) = block {
for inline in &p.children {
if let InlineNode::WikiLink(w) = inline {
return w.clone();
}
}
}
}
panic!("no wikilink in: {src}");
}
// ===== Parser-level: `wiki<N>:` / `wn.<Name>:` produce Interwiki =====
#[test]
fn numbered_interwiki_link_parses_to_wiki_index() {
let w = first_link("[[wiki1:OtherPage]]\n");
assert_eq!(w.target.kind, LinkKind::Interwiki);
assert_eq!(w.target.wiki_index, Some(1));
assert_eq!(w.target.path.as_deref(), Some("OtherPage"));
assert!(w.target.wiki_name.is_none());
}
#[test]
fn named_interwiki_link_parses_to_wiki_name() {
let w = first_link("[[wn.work:Project]]\n");
assert_eq!(w.target.kind, LinkKind::Interwiki);
assert_eq!(w.target.wiki_name.as_deref(), Some("work"));
assert_eq!(w.target.path.as_deref(), Some("Project"));
assert!(w.target.wiki_index.is_none());
}
#[test]
fn plain_wikilink_is_not_interwiki() {
let w = first_link("[[Home]]\n");
assert_eq!(w.target.kind, LinkKind::Wiki);
assert!(w.target.wiki_index.is_none());
assert!(w.target.wiki_name.is_none());
}
// ===== Config: `wikis = [...]` shape =====
#[test]
fn multi_wiki_config_loads_two_entries() {
let cfg = config_from_json(serde_json::json!({
"wikis": [
{ "root": "/tmp/personal", "name": "personal" },
{ "root": "/tmp/work", "name": "work" },
],
}));
assert_eq!(cfg.wikis.len(), 2);
assert_eq!(cfg.wikis[0].name, "personal");
assert_eq!(cfg.wikis[1].name, "work");
}
#[test]
fn v1_0_single_root_still_works_alongside_multi_wiki_shape() {
// Single-wiki shorthand is the legacy form; ensure it still desugars.
let cfg = config_from_json(serde_json::json!({
"wiki_root": "/tmp/legacy",
}));
assert_eq!(cfg.wikis.len(), 1);
assert_eq!(cfg.wikis[0].root, PathBuf::from("/tmp/legacy"));
}
// ===== Wiki aggregate + URI routing =====
#[test]
fn build_wikis_assigns_sequential_ids_starting_at_zero() {
let cfg = config_from_json(serde_json::json!({
"wikis": [
{ "root": "/tmp/p" },
{ "root": "/tmp/q" },
{ "root": "/tmp/r" },
],
}));
let wikis = build_wikis(&cfg.wikis);
assert_eq!(wikis.len(), 3);
for (i, w) in wikis.iter().enumerate() {
assert_eq!(w.id, WikiId(i as u32));
}
}
#[test]
fn resolve_uri_to_wiki_routes_to_the_owning_root() {
let cfg = config_from_json(serde_json::json!({
"wikis": [
{ "root": "/tmp/personal" },
{ "root": "/tmp/work" },
],
}));
let wikis = build_wikis(&cfg.wikis);
let uri = Url::from_file_path("/tmp/work/Project.wiki").unwrap();
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1)));
}
#[test]
fn nested_root_picks_longest_prefix_match() {
let cfg = config_from_json(serde_json::json!({
"wikis": [
{ "root": "/notes" },
{ "root": "/notes/sub" },
],
}));
let wikis = build_wikis(&cfg.wikis);
let uri = Url::from_file_path("/notes/sub/page.wiki").unwrap();
// The nested wiki wins because its root is a longer prefix.
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1)));
}
// ===== Cross-wiki resolution (uses WorkspaceIndex per wiki) =====
/// Verify the cross-wiki resolution semantics:
/// `[[wiki1:Page]]` looks up `Page` in the *second* wiki's index, not
/// the source wiki's. The pure-data check here mirrors what
/// `Backend::resolve_target_uri` does.
#[test]
fn interwiki_resolution_uses_target_wiki_index() {
// Build two wikis with overlapping page names so we can tell whose
// index was consulted.
let mut work = WorkspaceIndex::new(Some(PathBuf::from("/tmp/work")));
work.upsert(
Url::from_file_path("/tmp/work/Project.wiki").unwrap(),
&parse("= Work Project =\n"),
);
let mut personal = WorkspaceIndex::new(Some(PathBuf::from("/tmp/personal")));
personal.upsert(
Url::from_file_path("/tmp/personal/Project.wiki").unwrap(),
&parse("= Personal Project =\n"),
);
// Simulate the resolver: source is personal, target is `wiki1:Project`
// which should hit the work index (assumed to be index 1).
let w = first_link("[[wiki1:Project]]\n");
assert_eq!(w.target.wiki_index, Some(1));
let resolved = work.pages_by_name.get("Project").cloned();
assert!(resolved.is_some(), "work wiki should resolve Project");
assert!(
resolved
.unwrap()
.as_str()
.contains("/tmp/work/Project.wiki"),
"must come from the work wiki, not personal"
);
}
#[test]
fn interwiki_resolution_falls_back_when_wiki_missing() {
// Only one wiki registered; `[[wiki2:X]]` references the third
// wiki by index — no match expected.
let cfg = config_from_json(serde_json::json!({
"wikis": [ { "root": "/tmp/only" } ],
}));
let wikis = build_wikis(&cfg.wikis);
// Index 2 is out of range.
assert!(wikis.get(2).is_none());
}
// ===== Wiki commands: COMMANDS list completeness =====
#[test]
fn commands_list_includes_wiki_entries() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [
"nuwiki.wiki.listAll",
"nuwiki.wiki.select",
"nuwiki.wiki.openIndex",
"nuwiki.wiki.tabOpenIndex",
"nuwiki.wiki.gotoPage",
] {
assert!(names.contains(&name), "missing: {name}");
}
}
// ===== Multi-wiki link kind matrix =====
#[test]
fn link_kind_matrix_for_typical_targets() {
let cases = &[
("[[Home]]", LinkKind::Wiki),
("[[/Page]]", LinkKind::Wiki), // root-relative still Wiki
("[[//abs/path]]", LinkKind::Local), // filesystem-absolute
("[[wiki1:X]]", LinkKind::Interwiki),
("[[wn.work:Y]]", LinkKind::Interwiki),
("[[file:foo.pdf]]", LinkKind::File),
("[[local:foo.pdf]]", LinkKind::Local),
("[[diary:2026-05-11]]", LinkKind::Diary),
("[[#anchor]]", LinkKind::AnchorOnly),
];
for (src, expected) in cases {
let w = first_link(&format!("{src}\n"));
assert_eq!(w.target.kind, *expected, "case: {src}");
}
}
+240
View File
@@ -0,0 +1,240 @@
//! Tests for the navigation pieces: WorkspaceIndex queries,
//! page-name derivation, anchor slugify, position lookup, and link
//! resolution. The async LSP handlers are exercised through these
//! helpers; end-to-end JSON-RPC drive lives elsewhere.
use std::path::PathBuf;
use nuwiki_core::ast::{InlineNode, LinkKind};
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::index::{page_name_from_uri, slugify, WorkspaceIndex};
use nuwiki_lsp::nav::{find_inline_at, lsp_to_byte_pos};
use tower_lsp::lsp_types::{Position as LspPosition, Url};
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
// ===== Slugify =====
#[test]
fn slugify_basic_title() {
assert_eq!(slugify("Hello World"), "hello-world");
}
#[test]
fn slugify_strips_punctuation_and_collapses_dashes() {
assert_eq!(slugify("Hello, World! Again"), "hello-world-again");
}
#[test]
fn slugify_unicode_lowercase() {
assert_eq!(slugify("Crème Brûlée"), "crème-brûlée");
}
#[test]
fn slugify_no_trailing_dash() {
assert_eq!(slugify("Title — "), "title");
}
// ===== Page name from URI =====
#[test]
fn page_name_relative_to_root() {
let root = PathBuf::from("/wiki");
let uri = Url::from_file_path("/wiki/dir/Page.wiki").unwrap();
assert_eq!(page_name_from_uri(&uri, Some(&root)), "dir/Page");
}
#[test]
fn page_name_at_root() {
let root = PathBuf::from("/wiki");
let uri = Url::from_file_path("/wiki/Index.wiki").unwrap();
assert_eq!(page_name_from_uri(&uri, Some(&root)), "Index");
}
#[test]
fn page_name_without_root_falls_back_to_stem() {
let uri = Url::from_file_path("/tmp/foo.wiki").unwrap();
assert_eq!(page_name_from_uri(&uri, None), "foo");
}
// ===== Workspace index =====
#[test]
fn upsert_indexes_headings_and_outgoing_links() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
let ast = parse("= Home =\n[[Other]] and [[#Section]]\n");
idx.upsert(uri.clone(), &ast);
let page = idx.page(&uri).expect("page indexed");
assert_eq!(page.name, "Home");
assert_eq!(page.headings.len(), 1);
assert_eq!(page.headings[0].title, "Home");
assert_eq!(page.headings[0].anchor, "home");
assert_eq!(page.outgoing.len(), 2);
assert_eq!(page.outgoing[0].target_page.as_deref(), Some("Other"));
assert_eq!(page.outgoing[0].kind, LinkKind::Wiki);
assert_eq!(page.outgoing[1].kind, LinkKind::AnchorOnly);
}
#[test]
fn upsert_registers_page_by_name_and_records_backlinks() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
idx.upsert(home.clone(), &parse("[[About]]\n"));
idx.upsert(about.clone(), &parse("= About =\n"));
assert_eq!(idx.page_by_name("About").unwrap().uri, about);
let backlinks = idx.backlinks_for("About");
assert_eq!(backlinks.len(), 1);
assert_eq!(backlinks[0].source_uri, home);
}
#[test]
fn upsert_replaces_prior_indexing_for_same_uri() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
idx.upsert(uri.clone(), &parse("[[A]]\n"));
assert_eq!(idx.backlinks_for("A").len(), 1);
// Re-upsert with different content: old backlinks must drop.
idx.upsert(uri.clone(), &parse("[[B]]\n"));
assert_eq!(idx.backlinks_for("A").len(), 0);
assert_eq!(idx.backlinks_for("B").len(), 1);
}
#[test]
fn remove_drops_page_and_backlinks() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
idx.upsert(home.clone(), &parse("[[About]]\n"));
idx.upsert(about.clone(), &parse("= About =\n"));
idx.remove(&home);
assert!(idx.page(&home).is_none());
assert!(idx.backlinks_for("About").is_empty());
// The target page is untouched.
assert!(idx.page(&about).is_some());
}
#[test]
fn resolve_wiki_link_to_uri() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
idx.upsert(about.clone(), &parse("= About =\n"));
let target = nuwiki_core::ast::LinkTarget {
kind: LinkKind::Wiki,
path: Some("About".into()),
..Default::default()
};
assert_eq!(idx.resolve(&target, "Home"), Some(&about));
}
#[test]
fn resolve_anchor_only_link_returns_source_page() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
idx.upsert(home.clone(), &parse("= H =\n[[#Section]]\n"));
let target = nuwiki_core::ast::LinkTarget {
kind: LinkKind::AnchorOnly,
anchor: Some("section".into()),
..Default::default()
};
assert_eq!(idx.resolve(&target, "Home"), Some(&home));
}
#[test]
fn page_names_lists_indexed_pages_sorted() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
for name in ["Charlie", "Alpha", "Bravo"] {
let uri = Url::from_file_path(format!("/wiki/{name}.wiki")).unwrap();
idx.upsert(uri, &parse(&format!("= {name} =\n")));
}
let names = idx.page_names();
assert_eq!(names, vec!["Alpha", "Bravo", "Charlie"]);
}
// ===== Position conversion =====
#[test]
fn lsp_to_byte_pos_passthrough_in_utf8() {
let p = LspPosition {
line: 2,
character: 5,
};
let (line, col) = lsp_to_byte_pos(p, "ignored", true);
assert_eq!((line, col), (2, 5));
}
#[test]
fn lsp_to_byte_pos_converts_from_utf16() {
// 'é' is one UTF-16 code unit, two UTF-8 bytes. Char "2" after 'é':
// UTF-16 col 2 → byte col 3.
let text = "héllo\n";
let p = LspPosition {
line: 0,
character: 2,
};
let (line, col) = lsp_to_byte_pos(p, text, false);
assert_eq!(line, 0);
assert_eq!(col, 3);
}
// ===== Position lookup =====
#[test]
fn find_inline_at_returns_wikilink_under_cursor() {
let src = "see [[Other]] now\n";
let doc = parse(src);
// Cursor inside "Other" — byte col 6 falls inside "[[Other]]" (which
// starts at byte 4 and ends at byte 13).
let node = find_inline_at(&doc, 0, 6).expect("wikilink at cursor");
assert!(matches!(node, InlineNode::WikiLink(_)));
}
#[test]
fn find_inline_at_returns_wikilink_when_cursor_on_description() {
// Regression: pressing <CR> on the *title* part of a piped link used to
// descend into the description's Text node, so goto-definition saw a Text
// (not a WikiLink) and reported "No locations found".
let src = "see [[Other|My Title]] now\n";
let doc = parse(src);
// Byte col 16 is inside "Title" (the description), past the '|' at 11.
let node = find_inline_at(&doc, 0, 16).expect("wikilink at cursor");
assert!(matches!(node, InlineNode::WikiLink(_)));
}
#[test]
fn find_inline_at_returns_none_for_plain_text() {
let src = "just text\n";
let doc = parse(src);
let node = find_inline_at(&doc, 0, 3);
// "just text" → Text node, which is what we get back from the inner
// walk (not None). Verify it's a Text.
assert!(matches!(node, Some(InlineNode::Text(_))));
}
#[test]
fn find_inline_at_descends_into_inline_containers() {
let src = "*bold text*\n";
let doc = parse(src);
// Inside "bold" — should return the inner Text, not the wrapping Bold.
let node = find_inline_at(&doc, 0, 3).expect("hit");
assert!(matches!(node, InlineNode::Text(_)));
}
#[test]
fn find_inline_at_returns_none_past_eol() {
let src = "short\n";
let doc = parse(src);
assert!(find_inline_at(&doc, 0, 999).is_none());
}
+312
View File
@@ -0,0 +1,312 @@
//! Tests for the semantic-token builder, encoder, range filter, and
//! UTF-8/UTF-16 conversion.
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::semantic_tokens::{
build_data, collect_tokens, encode, legend, LineIndex, RawToken, MOD_CENTERED, MOD_LEVEL1,
MOD_LEVEL2, TOKEN_BOLD, TOKEN_CODE, TOKEN_CODE_BLOCK, TOKEN_COMMENT, TOKEN_DEFINITION_TERM,
TOKEN_HEADING, TOKEN_ITALIC, TOKEN_KEYWORD, TOKEN_MATH_BLOCK, TOKEN_TRANSCLUSION,
TOKEN_TYPE_NAMES, TOKEN_URL, TOKEN_WIKILINK,
};
use tower_lsp::lsp_types::{Position, Range};
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn first_with_kind(tokens: &[RawToken], kind: u32) -> Option<&RawToken> {
tokens.iter().find(|t| t.kind == kind)
}
// ===== Legend =====
#[test]
fn legend_lists_all_custom_token_types() {
let l = legend();
assert_eq!(l.token_types.len(), TOKEN_TYPE_NAMES.len());
let names: Vec<String> = l
.token_types
.iter()
.map(|t| t.as_str().to_owned())
.collect();
assert!(names.contains(&"vimwikiHeading".to_string()));
assert!(names.contains(&"vimwikiWikilink".to_string()));
assert!(names.contains(&"vimwikiTransclusion".to_string()));
}
// ===== Per-construct emission =====
#[test]
fn heading_emits_one_token_with_level_modifier() {
let doc = parse("== Hi ==\n");
let toks = collect_tokens(&doc, "== Hi ==\n");
let h = first_with_kind(&toks, TOKEN_HEADING).expect("heading token");
assert_eq!(h.mods & MOD_LEVEL2, MOD_LEVEL2);
assert_eq!(h.mods & MOD_CENTERED, 0);
}
#[test]
fn centered_heading_carries_centered_modifier() {
let src = " = Title =\n";
let doc = parse(src);
let toks = collect_tokens(&doc, src);
let h = first_with_kind(&toks, TOKEN_HEADING).expect("heading token");
assert_eq!(h.mods & MOD_LEVEL1, MOD_LEVEL1);
assert_eq!(h.mods & MOD_CENTERED, MOD_CENTERED);
}
#[test]
fn heading_does_not_emit_inner_inline_tokens() {
// Headings render as a single styled span, so bold inside is shadowed.
let src = "= Hi *bold* =\n";
let doc = parse(src);
let toks = collect_tokens(&doc, src);
assert!(toks.iter().any(|t| t.kind == TOKEN_HEADING));
assert!(
!toks.iter().any(|t| t.kind == TOKEN_BOLD),
"should not have emitted Bold inside a Heading"
);
}
#[test]
fn paragraph_emits_inline_tokens_only() {
let src = "see *bold* and `code`\n";
let doc = parse(src);
let toks = collect_tokens(&doc, src);
assert!(toks.iter().any(|t| t.kind == TOKEN_BOLD));
assert!(toks.iter().any(|t| t.kind == TOKEN_CODE));
assert!(!toks.iter().any(|t| t.kind == TOKEN_HEADING));
}
#[test]
fn italic_token_for_underscored_run() {
let src = "_emphasis_\n";
let doc = parse(src);
let toks = collect_tokens(&doc, src);
assert!(toks.iter().any(|t| t.kind == TOKEN_ITALIC));
}
#[test]
fn keyword_token_for_each_keyword() {
let src = "TODO write tests, FIXME later\n";
let doc = parse(src);
let toks = collect_tokens(&doc, src);
let kw_count = toks.iter().filter(|t| t.kind == TOKEN_KEYWORD).count();
assert_eq!(kw_count, 2);
}
#[test]
fn wikilink_and_external_link_get_different_types() {
let src = "[[Page]] and [[https://example.com|docs]]\n";
let doc = parse(src);
let toks = collect_tokens(&doc, src);
assert!(toks.iter().any(|t| t.kind == TOKEN_WIKILINK));
assert!(
toks.iter()
.any(|t| t.kind == nuwiki_lsp::semantic_tokens::TOKEN_EXTERNAL_LINK),
"URL inside [[...]] should be vimwikiExternalLink, got {toks:?}"
);
}
#[test]
fn raw_url_emits_url_token() {
let src = "see https://example.com here\n";
let doc = parse(src);
let toks = collect_tokens(&doc, src);
assert!(toks.iter().any(|t| t.kind == TOKEN_URL));
}
#[test]
fn transclusion_emits_transclusion_token() {
let src = "{{img.png|alt}}\n";
let doc = parse(src);
let toks = collect_tokens(&doc, src);
assert!(toks.iter().any(|t| t.kind == TOKEN_TRANSCLUSION));
}
#[test]
fn comment_token_for_single_line_comment() {
let src = "%% hidden\nstuff\n";
let doc = parse(src);
let toks = collect_tokens(&doc, src);
assert!(toks.iter().any(|t| t.kind == TOKEN_COMMENT));
}
#[test]
fn definition_term_token() {
let src = "Term:: Definition\n";
let doc = parse(src);
let toks = collect_tokens(&doc, src);
assert!(toks.iter().any(|t| t.kind == TOKEN_DEFINITION_TERM));
}
// ===== Multi-line fences =====
#[test]
fn preformatted_block_splits_into_one_token_per_line() {
let src = "{{{rust\nlet x = 1;\nlet y = 2;\n}}}\n";
let doc = parse(src);
let toks = collect_tokens(&doc, src);
let code_tokens: Vec<&RawToken> = toks.iter().filter(|t| t.kind == TOKEN_CODE_BLOCK).collect();
// Spans line 0 (open fence) through line 3 (close fence) = 4 lines, but
// empty trailing slices are dropped, so we expect 4 line tokens.
assert!(
code_tokens.len() >= 3,
"expected multi-line split, got {code_tokens:?}"
);
// No token should cross a line boundary.
for t in &code_tokens {
assert!(t.byte_len > 0);
}
}
#[test]
fn math_block_splits_into_one_token_per_line() {
let src = "{{$\nx=1\ny=2\n}}$\n";
let doc = parse(src);
let toks = collect_tokens(&doc, src);
let math_tokens: Vec<&RawToken> = toks.iter().filter(|t| t.kind == TOKEN_MATH_BLOCK).collect();
assert!(math_tokens.len() >= 3);
}
// ===== Sorting =====
#[test]
fn tokens_are_sorted_by_line_then_column() {
let src = "= H =\n*bold* and _italic_\n";
let doc = parse(src);
let toks = collect_tokens(&doc, src);
for w in toks.windows(2) {
let a = (w[0].line, w[0].byte_col);
let b = (w[1].line, w[1].byte_col);
assert!(a <= b, "tokens not sorted: {a:?} then {b:?}");
}
}
// ===== Encoding =====
#[test]
fn encode_produces_correct_delta_quintuples() {
let src = "= H =\n*bold*\n";
let raws = vec![
RawToken {
line: 0,
byte_col: 0,
byte_len: 5,
kind: TOKEN_HEADING,
mods: MOD_LEVEL1,
},
RawToken {
line: 1,
byte_col: 0,
byte_len: 6,
kind: TOKEN_BOLD,
mods: 0,
},
];
let idx = LineIndex::new(src);
let data = encode(&raws, src, &idx, true);
assert_eq!(data.len(), 10);
// First token: delta_line=0, delta_col=0, len=5, type=HEADING, mods=LEVEL1
assert_eq!(&data[0..5], &[0, 0, 5, TOKEN_HEADING, MOD_LEVEL1]);
// Second token: delta_line=1 (new line), delta_col=0 (absolute), len=6, type=BOLD, mods=0
assert_eq!(&data[5..10], &[1, 0, 6, TOKEN_BOLD, 0]);
}
#[test]
fn encode_uses_relative_column_within_same_line() {
let src = "abc def ghi\n";
let raws = vec![
RawToken {
line: 0,
byte_col: 0,
byte_len: 3,
kind: TOKEN_BOLD,
mods: 0,
},
RawToken {
line: 0,
byte_col: 4,
byte_len: 3,
kind: TOKEN_ITALIC,
mods: 0,
},
RawToken {
line: 0,
byte_col: 8,
byte_len: 3,
kind: TOKEN_CODE,
mods: 0,
},
];
let idx = LineIndex::new(src);
let data = encode(&raws, src, &idx, true);
assert_eq!(&data[0..5], &[0, 0, 3, TOKEN_BOLD, 0]);
// Second: delta_col = 4 - 0 = 4
assert_eq!(&data[5..10], &[0, 4, 3, TOKEN_ITALIC, 0]);
// Third: delta_col = 8 - 4 = 4
assert_eq!(&data[10..15], &[0, 4, 3, TOKEN_CODE, 0]);
}
#[test]
fn encode_converts_to_utf16_for_multibyte_chars() {
// "_é_" — italic delimiters around é. Span covers 4 bytes (1 + 2 + 1)
// but 3 UTF-16 code units.
let src = "_é_\n";
let raws = vec![RawToken {
line: 0,
byte_col: 0,
byte_len: 4,
kind: TOKEN_ITALIC,
mods: 0,
}];
let idx = LineIndex::new(src);
let data = encode(&raws, src, &idx, false);
// length should be in UTF-16 code units (3), not bytes (4).
assert_eq!(data[2], 3);
}
// ===== Range filter =====
#[test]
fn range_filter_keeps_only_overlapping_tokens() {
let src = "= L1 =\n= L2 =\n= L3 =\n";
let doc = parse(src);
// Restrict to line 1 only.
let range = Range {
start: Position {
line: 1,
character: 0,
},
end: Position {
line: 2,
character: 0,
},
};
let data = build_data(&doc, src, true, Some(range));
// Expect exactly 1 heading token (the L2 one).
assert_eq!(data.len(), 5, "got {data:?}");
// Verify it's on line 1 (delta_line = 1 from prev_line = 0).
assert_eq!(data[0], 1);
}
#[test]
fn empty_document_produces_empty_token_stream() {
let doc = parse("");
let data = build_data(&doc, "", true, None);
assert!(data.is_empty());
}
// ===== End-to-end through builder =====
#[test]
fn build_data_matches_collect_then_encode() {
let src = "= H1 =\n_em_ TODO\n";
let doc = parse(src);
let direct = build_data(&doc, src, true, None);
let raws = collect_tokens(&doc, src);
let idx = LineIndex::new(src);
let manual = encode(&raws, src, &idx, true);
assert_eq!(direct, manual);
}