2026-05-30 18:35:40 +00:00
|
|
|
//! Link-health diagnostics + TOC/links/orphans queries.
|
2026-05-11 20:49:32 +00:00
|
|
|
|
|
|
|
|
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()),
|
|
|
|
|
];
|
2026-06-03 12:04:36 +00:00
|
|
|
let out = ops::build_toc_text(&items, "Contents", 1, 0, 0);
|
2026-05-11 20:49:32 +00:00
|
|
|
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() {
|
2026-06-03 12:04:36 +00:00
|
|
|
let out = ops::build_toc_text(&[], "Contents", 1, 0, 0);
|
2026-05-11 20:49:32 +00:00
|
|
|
assert_eq!(out, "= Contents =\n");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 12:04:36 +00:00
|
|
|
#[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);
|
2026-06-03 14:13:55 +00:00
|
|
|
assert!(
|
|
|
|
|
bare.contains("- [[About]]") && !bare.contains("About Us"),
|
|
|
|
|
"bare: {bare}"
|
|
|
|
|
);
|
2026-06-03 12:04:36 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 18:55:51 +00:00
|
|
|
#[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",
|
2026-06-02 10:50:38 +00:00
|
|
|
1,
|
2026-06-03 12:04:36 +00:00
|
|
|
0,
|
|
|
|
|
None
|
2026-05-31 18:55:51 +00:00
|
|
|
)
|
|
|
|
|
.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",
|
2026-06-02 10:50:38 +00:00
|
|
|
1,
|
2026-06-03 12:04:36 +00:00
|
|
|
0,
|
|
|
|
|
None
|
2026-05-31 18:55:51 +00:00
|
|
|
)
|
|
|
|
|
.is_some());
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 18:49:00 +00:00
|
|
|
#[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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 18:44:35 +00:00
|
|
|
#[test]
|
|
|
|
|
fn captions_honour_custom_header_and_level() {
|
|
|
|
|
// toc_header="Table of Contents", level 2 → `== Table of Contents ==`.
|
2026-06-03 12:04:36 +00:00
|
|
|
let out = ops::build_toc_text(&[], "Table of Contents", 2, 0, 0);
|
2026-05-31 18:44:35 +00:00
|
|
|
assert_eq!(out, "== Table of Contents ==\n");
|
|
|
|
|
// links_header at level 3.
|
|
|
|
|
let pages = vec!["A".to_string(), "B".to_string()];
|
2026-06-03 12:04:36 +00:00
|
|
|
let links = ops::build_links_text(&pages, "All Pages", 3, None, 0, None);
|
2026-05-31 18:44:35 +00:00
|
|
|
assert!(links.starts_with("=== All Pages ===\n"));
|
|
|
|
|
// level clamps to 1..=6.
|
2026-06-03 12:04:36 +00:00
|
|
|
assert!(ops::build_toc_text(&[], "X", 9, 0, 0).starts_with("====== X ======"));
|
|
|
|
|
assert!(ops::build_toc_text(&[], "X", 0, 0, 0).starts_with("= X ="));
|
2026-06-02 10:50:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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()];
|
2026-06-03 12:04:36 +00:00
|
|
|
let links = ops::build_links_text(&pages, "Generated Links", 1, None, 2, None);
|
2026-06-02 10:50:38 +00:00
|
|
|
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,
|
2026-06-03 12:04:36 +00:00
|
|
|
0,
|
2026-06-02 10:50:38 +00:00
|
|
|
);
|
|
|
|
|
// 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:?}");
|
2026-05-31 18:44:35 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-11 20:49:32 +00:00
|
|
|
// ===== Links text generation =====
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn build_links_text_excludes_current_page() {
|
|
|
|
|
let pages = vec!["A".to_string(), "Home".to_string(), "B".to_string()];
|
2026-06-03 12:04:36 +00:00
|
|
|
let out = ops::build_links_text(&pages, "Generated Links", 1, Some("Home"), 0, None);
|
2026-05-11 20:49:32 +00:00
|
|
|
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()];
|
2026-06-03 12:04:36 +00:00
|
|
|
let out = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None);
|
2026-05-11 20:49:32 +00:00
|
|
|
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]
|
2026-05-14 19:34:01 +00:00
|
|
|
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 ==`).
|
2026-05-11 20:49:32 +00:00
|
|
|
let src = "== Contents ==\n- [[A]]\n";
|
|
|
|
|
let ast = parse(src);
|
2026-05-14 19:34:01 +00:00
|
|
|
assert!(ops::find_section_range(&ast, "Contents").is_some());
|
2026-05-11 20:49:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 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();
|
2026-06-03 12:04:36 +00:00
|
|
|
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).expect("got an edit");
|
2026-05-11 20:49:32 +00:00
|
|
|
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"));
|
2026-06-03 15:09:33 +00:00
|
|
|
// 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]]"));
|
2026-05-11 20:49:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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();
|
2026-06-03 12:04:36 +00:00
|
|
|
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).expect("got an edit");
|
2026-05-11 20:49:32 +00:00
|
|
|
let changes = edit.changes.expect("changes map");
|
|
|
|
|
let edits = &changes[&uri];
|
|
|
|
|
let te = &edits[0];
|
2026-06-03 15:09:33 +00:00
|
|
|
assert!(te.new_text.contains("[[#Real|Real]]"));
|
2026-05-11 20:49:32 +00:00
|
|
|
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();
|
2026-06-03 12:04:36 +00:00
|
|
|
assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).is_none());
|
2026-05-11 20:49:32 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-30 23:12:08 -03:00
|
|
|
#[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!(
|
2026-06-03 12:04:36 +00:00
|
|
|
ops::toc_rebuild_edit(without, &ast, &uri, true, "Contents", 1, 0, 0).is_none(),
|
2026-05-30 23:12:08 -03:00
|
|
|
"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);
|
2026-06-02 10:50:38 +00:00
|
|
|
let edit =
|
2026-06-03 12:04:36 +00:00
|
|
|
ops::toc_rebuild_edit(with, &ast, &uri, true, "Contents", 1, 0, 0).expect("rebuild edit");
|
2026-05-30 23:12:08 -03:00
|
|
|
let te = &edit.changes.unwrap()[&uri][0];
|
2026-06-03 15:09:33 +00:00
|
|
|
assert!(te.new_text.contains("[[#Real|Real]]"));
|
2026-05-30 23:12:08 -03:00
|
|
|
assert!(!te.new_text.contains("Stale"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 20:49:32 +00:00
|
|
|
// ===== 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()];
|
2026-06-02 10:50:38 +00:00
|
|
|
let edit = ops::links_edit(
|
|
|
|
|
src,
|
|
|
|
|
&ast,
|
|
|
|
|
&uri,
|
|
|
|
|
"Home",
|
|
|
|
|
&pages,
|
|
|
|
|
true,
|
|
|
|
|
"Generated Links",
|
|
|
|
|
1,
|
|
|
|
|
0,
|
2026-06-03 12:04:36 +00:00
|
|
|
None,
|
2026-06-02 10:50:38 +00:00
|
|
|
)
|
|
|
|
|
.expect("edit");
|
2026-05-11 20:49:32 +00:00
|
|
|
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_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()];
|
2026-06-02 10:50:38 +00:00
|
|
|
let edit = ops::links_edit(
|
|
|
|
|
src,
|
|
|
|
|
&ast,
|
|
|
|
|
&uri,
|
|
|
|
|
"Home",
|
|
|
|
|
&pages,
|
|
|
|
|
true,
|
|
|
|
|
"Generated Links",
|
|
|
|
|
1,
|
|
|
|
|
0,
|
2026-06-03 12:04:36 +00:00
|
|
|
None,
|
2026-06-02 10:50:38 +00:00
|
|
|
)
|
|
|
|
|
.expect("edit");
|
2026-05-11 20:49:32 +00:00
|
|
|
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();
|
2026-06-03 12:04:36 +00:00
|
|
|
assert!(ops::links_edit(
|
|
|
|
|
src,
|
|
|
|
|
&ast,
|
|
|
|
|
&uri,
|
|
|
|
|
"Home",
|
|
|
|
|
&[],
|
|
|
|
|
true,
|
|
|
|
|
"Generated Links",
|
|
|
|
|
1,
|
|
|
|
|
0,
|
|
|
|
|
None
|
|
|
|
|
)
|
|
|
|
|
.is_none());
|
2026-05-11 20:49:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 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}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 22:30:32 -03:00
|
|
|
// ===== 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,
|
|
|
|
|
);
|
2026-05-26 23:34:31 -03:00
|
|
|
assert!(
|
|
|
|
|
diags.is_empty(),
|
|
|
|
|
"expected no diagnostics, got: {:?}",
|
|
|
|
|
diags
|
|
|
|
|
);
|
2026-05-26 22:30:32 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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,
|
2026-05-26 23:34:31 -03:00
|
|
|
&[("index", "[[posts/foo|Foo]]\n"), ("posts/foo", "= Foo =\n")],
|
2026-05-26 22:30:32 -03:00
|
|
|
);
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 22:29:30 -03:00
|
|
|
// ===== 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 20:49:32 +00:00
|
|
|
// ===== COMMANDS completeness =====
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-30 18:35:40 +00:00
|
|
|
fn commands_list_includes_link_health_entries() {
|
2026-05-11 20:49:32 +00:00
|
|
|
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}");
|
|
|
|
|
}
|
|
|
|
|
}
|