//! Diagnostic sources beyond parse-time `ErrorNode`s. //! //! The `nuwiki.link` source emits broken-link warnings. Walks //! every `WikiLinkNode` in the AST and emits one diagnostic per: //! - `Wiki` target that doesn't resolve to a page in the workspace index, //! - `Wiki`/`AnchorOnly` target with an anchor that matches neither a //! heading nor a `:tag:` on the target page, //! - `File`/`Local` target whose resolved path doesn't exist on disk. //! //! Interwiki, Diary, Raw, and external links are not diagnosed — //! interwiki resolution and diary handling live elsewhere, and we //! don't fetch URLs. use std::path::PathBuf; use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Url}; use nuwiki_core::ast::{ BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, ListItemNode, ListNode, TableNode, WikiLinkNode, }; use crate::config::LinkSeverity; use crate::index::{OutgoingLink, WorkspaceIndex}; use crate::to_lsp_range; pub const LINK_SOURCE: &str = "nuwiki.link"; /// Convert a [`LinkSeverity`] to its LSP counterpart. `Off` returns `None` /// so callers can short-circuit before walking the document. pub fn severity_to_lsp(s: LinkSeverity) -> Option { match s { LinkSeverity::Off => None, LinkSeverity::Hint => Some(DiagnosticSeverity::HINT), LinkSeverity::Warning => Some(DiagnosticSeverity::WARNING), LinkSeverity::Error => Some(DiagnosticSeverity::ERROR), } } /// Walk the AST and collect every `WikiLinkNode` in source order. pub fn collect_wiki_links(ast: &DocumentNode) -> Vec<&WikiLinkNode> { let mut out = Vec::new(); for block in &ast.children { walk_block(block, &mut out); } out } fn walk_block<'a>(block: &'a BlockNode, out: &mut Vec<&'a WikiLinkNode>) { match block { BlockNode::Heading(h) => walk_inlines(&h.children, out), BlockNode::Paragraph(p) => walk_inlines(&p.children, out), BlockNode::Blockquote(BlockquoteNode { children, .. }) => { for c in children { walk_block(c, out); } } BlockNode::List(ListNode { items, .. }) => { for item in items { walk_list_item(item, out); } } BlockNode::DefinitionList(dl) => { for item in &dl.items { if let Some(t) = &item.term { walk_inlines(t, out); } for d in &item.definitions { walk_inlines(d, out); } } } BlockNode::Table(TableNode { rows, .. }) => { for row in rows { for cell in &row.cells { walk_inlines(&cell.children, out); } } } _ => {} } } fn walk_list_item<'a>(item: &'a ListItemNode, out: &mut Vec<&'a WikiLinkNode>) { walk_inlines(&item.children, out); if let Some(sub) = &item.sublist { for sub_it in &sub.items { walk_list_item(sub_it, out); } } } fn walk_inlines<'a>(inlines: &'a [InlineNode], out: &mut Vec<&'a WikiLinkNode>) { for n in inlines { match n { InlineNode::WikiLink(w) => { out.push(w); if let Some(d) = &w.description { walk_inlines(d, out); } } InlineNode::Bold(b) => walk_inlines(&b.children, out), InlineNode::Italic(i) => walk_inlines(&i.children, out), InlineNode::BoldItalic(bi) => walk_inlines(&bi.children, out), InlineNode::Strikethrough(s) => walk_inlines(&s.children, out), InlineNode::Superscript(s) => walk_inlines(&s.children, out), InlineNode::Subscript(s) => walk_inlines(&s.children, out), InlineNode::Color(c) => walk_inlines(&c.children, out), InlineNode::ExternalLink(e) => { if let Some(d) = &e.description { walk_inlines(d, out); } } _ => {} } } } /// Classification of why a single wikilink is broken. Returned by /// [`classify_link`] so callers (diagnostics + `nuwiki.workspace.checkLinks`) /// share the same logic. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BrokenLinkKind { /// Target page doesn't exist in the workspace index. MissingPage(String), /// Page exists but the `#anchor` doesn't match any heading or `:tag:`. MissingAnchor { page: String, anchor: String }, /// `file:` / `local:` target whose resolved path isn't on disk. MissingFile(PathBuf), } impl BrokenLinkKind { pub fn message(&self) -> String { match self { BrokenLinkKind::MissingPage(p) => format!("broken wikilink: page '{p}' not in wiki"), BrokenLinkKind::MissingAnchor { page, anchor } => { format!("broken anchor: '#{anchor}' not found on page '{page}'") } BrokenLinkKind::MissingFile(p) => { format!("broken file link: '{}' not found", p.display()) } } } /// Short machine-readable kind, used in the `checkLinks` JSON output so /// editors can colour-code or filter. pub fn tag(&self) -> &'static str { match self { BrokenLinkKind::MissingPage(_) => "missing-page", BrokenLinkKind::MissingAnchor { .. } => "missing-anchor", BrokenLinkKind::MissingFile(_) => "missing-file", } } } /// Inspect one wikilink and return `Some(_)` when it's broken. The `uri` /// is the source document's URI — required for `file:` / `local:` /// resolution. `source_page` is the source's name in the index (used by /// `AnchorOnly` links to look up the current page's headings/tags). pub fn classify_link( link: &WikiLinkNode, uri: Option<&Url>, index: &WorkspaceIndex, source_page: &str, ) -> Option { match link.target.kind { LinkKind::Wiki => { let path = link.target.path.as_deref()?; match index.page_for_wiki_target(path, source_page, link.target.is_absolute) { None => Some(BrokenLinkKind::MissingPage(path.to_string())), Some(page) => { if let Some(anchor) = &link.target.anchor { if anchor_resolves_on(page, anchor) { None } else { Some(BrokenLinkKind::MissingAnchor { page: path.to_string(), anchor: anchor.clone(), }) } } else { None } } } } LinkKind::AnchorOnly => { let anchor = link.target.anchor.as_deref()?; let page = index.page_by_name(source_page)?; if anchor_resolves_on(page, anchor) { None } else { Some(BrokenLinkKind::MissingAnchor { page: source_page.to_string(), anchor: anchor.to_string(), }) } } LinkKind::File | LinkKind::Local => { let path = link.target.path.as_deref()?; let resolved = resolve_file_path(uri, index, path, link.target.is_absolute)?; if resolved.exists() { None } else { Some(BrokenLinkKind::MissingFile(resolved)) } } LinkKind::Interwiki | LinkKind::Diary | LinkKind::Raw => None, } } fn anchor_resolves_on(page: &crate::index::IndexedPage, anchor: &str) -> bool { page.find_heading_by_anchor(anchor).is_some() || page.tags.iter().any(|t| t.name == anchor) } /// Same classification as [`classify_link`] but driven from the cached /// [`OutgoingLink`] data so workspace-wide checks don't have to re-parse /// every page. The on-disk `File`/`Local` check is performed when the /// source URI is available. pub fn classify_outgoing( link: &OutgoingLink, source_uri: &Url, index: &WorkspaceIndex, source_page: &str, ) -> Option { match link.kind { LinkKind::Wiki => { let path = link.target_page.as_deref()?; match index.page_for_wiki_target(path, source_page, link.is_absolute) { None => Some(BrokenLinkKind::MissingPage(path.to_string())), Some(page) => { if let Some(anchor) = &link.anchor { if anchor_resolves_on(page, anchor) { None } else { Some(BrokenLinkKind::MissingAnchor { page: path.to_string(), anchor: anchor.clone(), }) } } else { None } } } } LinkKind::AnchorOnly => { let anchor = link.anchor.as_deref()?; let page = index.page_by_name(source_page)?; if anchor_resolves_on(page, anchor) { None } else { Some(BrokenLinkKind::MissingAnchor { page: source_page.to_string(), anchor: anchor.to_string(), }) } } LinkKind::File | LinkKind::Local => { let path = link.target_page.as_deref()?; let resolved = resolve_file_path(Some(source_uri), index, path, link.is_absolute)?; if resolved.exists() { None } else { Some(BrokenLinkKind::MissingFile(resolved)) } } LinkKind::Interwiki | LinkKind::Diary | LinkKind::Raw => None, } } /// Flatten heading inlines down to a plain string. Used by `commands::ops` /// to match heading text against the configured TOC/Links section name. pub fn heading_text(inlines: &[InlineNode]) -> String { let mut s = String::new(); push_inline_text(inlines, &mut s); s.trim().to_string() } fn push_inline_text(inlines: &[InlineNode], out: &mut String) { for n in inlines { match n { InlineNode::Text(t) => out.push_str(&t.content), InlineNode::Bold(b) => push_inline_text(&b.children, out), InlineNode::Italic(i) => push_inline_text(&i.children, out), InlineNode::BoldItalic(bi) => push_inline_text(&bi.children, out), InlineNode::Strikethrough(s) => push_inline_text(&s.children, out), InlineNode::Code(c) => out.push_str(&c.content), InlineNode::Superscript(s) => push_inline_text(&s.children, out), InlineNode::Subscript(s) => push_inline_text(&s.children, out), InlineNode::Color(c) => push_inline_text(&c.children, out), InlineNode::WikiLink(w) => match &w.description { Some(d) => push_inline_text(d, out), None => { if let Some(p) = &w.target.path { out.push_str(p); } } }, InlineNode::ExternalLink(e) => match &e.description { Some(d) => push_inline_text(d, out), None => out.push_str(&e.url), }, _ => {} } } } /// Resolve a `file:` / `local:` link target to an absolute path. /// /// - Absolute (parsed `//path` or `is_absolute`): used as-is. /// - Otherwise: joined with the source file's parent directory. /// - If we don't have a URI to anchor relative paths against, falls back /// to the wiki root from the index. fn resolve_file_path( uri: Option<&Url>, index: &WorkspaceIndex, path: &str, is_absolute: bool, ) -> Option { let candidate = PathBuf::from(path); if is_absolute || candidate.is_absolute() { return Some(candidate); } if let Some(uri) = uri { if let Ok(base) = uri.to_file_path() { if let Some(parent) = base.parent() { return Some(parent.join(&candidate)); } } } index.root.as_ref().map(|r| r.join(&candidate)) } /// `nuwiki.link` diagnostics for a single document. pub fn link_health( ast: &DocumentNode, text: &str, uri: Option<&Url>, index: &WorkspaceIndex, source_page: &str, severity: LinkSeverity, utf8: bool, ) -> Vec { let Some(sev) = severity_to_lsp(severity) else { return Vec::new(); }; let mut out = Vec::new(); for link in collect_wiki_links(ast) { if let Some(kind) = classify_link(link, uri, index, source_page) { out.push(Diagnostic { range: to_lsp_range(&link.span, text, utf8), severity: Some(sev), source: Some(LINK_SOURCE.into()), message: kind.message(), code: Some(tower_lsp::lsp_types::NumberOrString::String( kind.tag().to_string(), )), ..Diagnostic::default() }); } } out }