feat: initial nuwiki Rust workspace (core, lsp, ls)
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
//! 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, and
|
||||
/// (via `nuwiki_core::ast::inline_text`) the same text the HTML renderer uses
|
||||
/// for heading `id`s — so anchors validated here match anchors emitted there.
|
||||
pub fn heading_text(inlines: &[InlineNode]) -> String {
|
||||
nuwiki_core::ast::inline_text(inlines)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
Reference in New Issue
Block a user