//! 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_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).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}"); } } // ===== 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}"); } }