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