94cb58064d
:NuwikiTOC inserted a new table of contents at the top of the file. It now inserts at the cursor line instead, so you can place the TOC where you want it (a blank line separates it from preceding text). An existing TOC is still refreshed in place, and auto_toc-on-save is unaffected. The Vim/Lua clients send the 0-based cursor line with nuwiki.toc.generate; the server threads it into toc_edit (clamped to the document) and falls back to the top of the file when absent. toc_rebuild_edit passes None. Tests: toc_edit_inserts_at_cursor_line + toc_edit_cursor_line_clamped_and_existing_replaced_in_place. 575 passed, clippy clean, keymap harnesses green. Doc updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
865 lines
25 KiB
Rust
865 lines
25 KiB
Rust
//! 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,
|
|
)
|
|
.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_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,
|
|
)
|
|
.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
|
|
)
|
|
.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}");
|
|
}
|
|
}
|