feat: initial nuwiki Rust workspace (core, lsp, ls)
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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 ==");
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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 Jan–Mar; 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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 & B <ok>"));
|
||||
}
|
||||
|
||||
// ===== 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("<script>"), "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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user