phase 15: link health + TOC/links/orphans
New diagnostics module hosts the `nuwiki.link` source: walks every
`WikiLinkNode` and emits a diagnostic per broken target. Severity is
gated by `cfg.link_severity` (off | hint | warn | error). Wired into
`collect_diagnostics` so didOpen/didChange publish link diagnostics
alongside parse errors.
Classification (`BrokenLinkKind`):
- `Wiki` target missing from the workspace index → MissingPage.
- `Wiki`/`AnchorOnly` target with an anchor that matches neither a
heading nor a `:tag:` on the resolved page → MissingAnchor.
- `file:` / `local:` target whose resolved path isn't on disk →
MissingFile. Absolute paths are used as-is; relative paths resolve
against the source URI's parent directory, falling back to the wiki
root.
- Interwiki/Diary/Raw/external — never diagnosed.
`classify_link` works off a `&WikiLinkNode`; `classify_outgoing` does
the same job from a cached `IndexedPage.outgoing` entry so the
workspace-wide checker doesn't need to re-parse every page.
New commands:
- `nuwiki.toc.generate` — generate `= Contents =` heading + nested
list of `[[#anchor|Title]]` entries from current headings. Replaces
any existing h1 "Contents" + its immediate following list
case-insensitively; inserts at line 0 otherwise. Skips the TOC's
own heading so re-generation is idempotent.
- `nuwiki.links.generate` — equivalent of `:VimwikiGenerateLinks`.
Flat alphabetical list under `= Generated Links =`, excludes the
current page.
- `nuwiki.workspace.checkLinks` — returns `Vec<BrokenLinkEntry>` with
`{ uri, range, kind, message }` per broken link across the wiki.
Open documents use live text for range conversion; closed pages
fall back to the stored span coords. Accepts an optional `uri` to
pick a specific wiki; defaults to the first registered one.
- `nuwiki.workspace.findOrphans` — returns `Vec<{ uri, name }>` for
every indexed page with no incoming links. Sorted alphabetically.
Pure ops (`commands::ops`):
- `build_toc_text(items, heading_name)` — formats the TOC body with
2-space indent per level, anchors via `index::slugify`.
- `build_links_text(pages, heading_name, exclude)` — formats the flat
list.
- `find_section_range(ast, heading_name)` — locates an existing h1
section (heading + immediate following list) by case-insensitive
title match.
- `toc_edit` / `links_edit` — full edit producers.
- `collect_workspace_broken_links` / `find_orphans`.
Plumbing:
- `collect_diagnostics` gains a `uri: Option<&Url>` parameter so
link-health can resolve `file:` / `local:` relatives. `ast_diagnostics`
back-compat wrapper preserved.
- `Backend::default_wiki` is no longer `#[allow(dead_code)]`.
Tests: 31 new in `phase15_link_health.rs` covering severity mapping,
classification on every kind/anchor case, TOC nesting, links exclusion,
section-range case-insensitivity, idempotent re-generation, orphan
detection, and the COMMANDS list. The Phase 11 stub test was rewritten
into a real "no index = no diagnostics" assertion. All 274 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
//! Phase 15: 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");
|
||||
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");
|
||||
assert_eq!(out, "= Contents =\n");
|
||||
}
|
||||
|
||||
// ===== 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", Some("Home"));
|
||||
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", 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_only_matches_h1() {
|
||||
let src = "== Contents ==\n- [[A]]\n";
|
||||
let ast = parse(src);
|
||||
// h2 — should not match.
|
||||
assert!(ops::find_section_range(&ast, "Contents").is_none());
|
||||
}
|
||||
|
||||
// ===== 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).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"));
|
||||
assert!(te.new_text.contains("[[#one|One]]"));
|
||||
}
|
||||
|
||||
#[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).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).is_none());
|
||||
}
|
||||
|
||||
// ===== 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).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).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).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}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== COMMANDS completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_phase15_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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user