Files
nuwiki/crates/nuwiki-lsp/src/diagnostics.rs
T
gffranco 21b485c91b Remove stale phase/spec references from code comments
Strip "Phase N", "SPEC §X.Y", and "v1.x" planning labels from comments
across the Rust crates and editor layers now that those tracking
artifacts are gone. Comments only — no logic, strings, or test names
touched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:25:51 -03:00

365 lines
13 KiB
Rust

//! 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<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()?;
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<BrokenLinkKind> {
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<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.
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
}