2026-05-11 20:49:32 +00:00
|
|
|
//! Diagnostic sources beyond parse-time `ErrorNode`s.
|
|
|
|
|
//!
|
|
|
|
|
//! Phase 15 lands the `nuwiki.link` source — 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 is Phase 18's job, diary is Phase 16's, 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<DiagnosticSeverity> {
|
|
|
|
|
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<BrokenLinkKind> {
|
|
|
|
|
match link.target.kind {
|
|
|
|
|
LinkKind::Wiki => {
|
|
|
|
|
let path = link.target.path.as_deref()?;
|
2026-05-26 22:30:32 -03:00
|
|
|
match index.page_for_wiki_target(path, source_page, link.target.is_absolute) {
|
2026-05-11 20:49:32 +00:00
|
|
|
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 {
|
2026-05-26 22:29:30 -03:00
|
|
|
page.find_heading_by_anchor(anchor).is_some()
|
|
|
|
|
|| page.tags.iter().any(|t| t.name == anchor)
|
2026-05-11 20:49:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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<BrokenLinkKind> {
|
|
|
|
|
match link.kind {
|
|
|
|
|
LinkKind::Wiki => {
|
|
|
|
|
let path = link.target_page.as_deref()?;
|
2026-05-26 22:30:32 -03:00
|
|
|
match index.page_for_wiki_target(path, source_page, link.is_absolute) {
|
2026-05-11 20:49:32 +00:00
|
|
|
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()?;
|
2026-05-26 22:30:32 -03:00
|
|
|
let resolved = resolve_file_path(Some(source_uri), index, path, link.is_absolute)?;
|
2026-05-11 20:49:32 +00:00
|
|
|
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<PathBuf> {
|
|
|
|
|
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. Phase 11 left this as
|
|
|
|
|
/// a stub; Phase 15 fills it in.
|
|
|
|
|
pub fn link_health(
|
|
|
|
|
ast: &DocumentNode,
|
|
|
|
|
text: &str,
|
|
|
|
|
uri: Option<&Url>,
|
|
|
|
|
index: &WorkspaceIndex,
|
|
|
|
|
source_page: &str,
|
|
|
|
|
severity: LinkSeverity,
|
|
|
|
|
utf8: bool,
|
|
|
|
|
) -> Vec<Diagnostic> {
|
|
|
|
|
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
|
|
|
|
|
}
|