phase 15: link health + TOC/links/orphans
CI / cargo fmt --check (push) Successful in 23s
CI / cargo clippy (push) Successful in 1m29s
CI / cargo test (push) Successful in 1m40s

New diagnostics module hosts the `nuwiki.link` source: walks every
`WikiLinkNode` and emits a diagnostic per broken target. Severity is
gated by `cfg.link_severity` (off | hint | warn | error). Wired into
`collect_diagnostics` so didOpen/didChange publish link diagnostics
alongside parse errors.

Classification (`BrokenLinkKind`):
- `Wiki` target missing from the workspace index → MissingPage.
- `Wiki`/`AnchorOnly` target with an anchor that matches neither a
  heading nor a `:tag:` on the resolved page → MissingAnchor.
- `file:` / `local:` target whose resolved path isn't on disk →
  MissingFile. Absolute paths are used as-is; relative paths resolve
  against the source URI's parent directory, falling back to the wiki
  root.
- Interwiki/Diary/Raw/external — never diagnosed.

`classify_link` works off a `&WikiLinkNode`; `classify_outgoing` does
the same job from a cached `IndexedPage.outgoing` entry so the
workspace-wide checker doesn't need to re-parse every page.

New commands:
- `nuwiki.toc.generate` — generate `= Contents =` heading + nested
  list of `[[#anchor|Title]]` entries from current headings. Replaces
  any existing h1 "Contents" + its immediate following list
  case-insensitively; inserts at line 0 otherwise. Skips the TOC's
  own heading so re-generation is idempotent.
- `nuwiki.links.generate` — equivalent of `:VimwikiGenerateLinks`.
  Flat alphabetical list under `= Generated Links =`, excludes the
  current page.
- `nuwiki.workspace.checkLinks` — returns `Vec<BrokenLinkEntry>` with
  `{ uri, range, kind, message }` per broken link across the wiki.
  Open documents use live text for range conversion; closed pages
  fall back to the stored span coords. Accepts an optional `uri` to
  pick a specific wiki; defaults to the first registered one.
- `nuwiki.workspace.findOrphans` — returns `Vec<{ uri, name }>` for
  every indexed page with no incoming links. Sorted alphabetically.

Pure ops (`commands::ops`):
- `build_toc_text(items, heading_name)` — formats the TOC body with
  2-space indent per level, anchors via `index::slugify`.
- `build_links_text(pages, heading_name, exclude)` — formats the flat
  list.
- `find_section_range(ast, heading_name)` — locates an existing h1
  section (heading + immediate following list) by case-insensitive
  title match.
- `toc_edit` / `links_edit` — full edit producers.
- `collect_workspace_broken_links` / `find_orphans`.

Plumbing:
- `collect_diagnostics` gains a `uri: Option<&Url>` parameter so
  link-health can resolve `file:` / `local:` relatives. `ast_diagnostics`
  back-compat wrapper preserved.
- `Backend::default_wiki` is no longer `#[allow(dead_code)]`.

Tests: 31 new in `phase15_link_health.rs` covering severity mapping,
classification on every kind/anchor case, TOC nesting, links exclusion,
section-range case-insensitivity, idempotent re-generation, orphan
detection, and the COMMANDS list. The Phase 11 stub test was rewritten
into a real "no index = no diagnostics" assertion. All 274 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 20:49:32 +00:00
parent f1d046fad1
commit ce3ac8c4f8
6 changed files with 1302 additions and 23 deletions
+407 -5
View File
@@ -1,11 +1,10 @@
//! `workspace/executeCommand` dispatcher.
//!
//! Phase 13 introduced the router + `nuwiki.file.delete`. Phase 14 adds
//! Phase 13 introduced the router + `nuwiki.file.delete`. Phase 14 added
//! the cheap surgical edits — checkbox toggle/cycle/reject, heading
//! promote/demote, "next task" navigation — that account for the bulk
//! of vimwiki users' daily keymap traffic. Heavier commands (table
//! reflow, list renumber, link normalisation, colorize) are scoped for
//! a follow-up since each needs its own structural rewriter.
//! promote/demote, "next task" navigation. Phase 15 layers on the
//! generation + workspace-query commands: `toc.generate`,
//! `links.generate`, `workspace.checkLinks`, `workspace.findOrphans`.
//!
//! Every command resolves to either a `WorkspaceEdit` (the server then
//! calls `apply_edit`) or a JSON `Value` (returned to the client) — see
@@ -40,6 +39,10 @@ pub const COMMANDS: &[&str] = &[
"nuwiki.list.nextTask",
"nuwiki.heading.addLevel",
"nuwiki.heading.removeLevel",
"nuwiki.toc.generate",
"nuwiki.links.generate",
"nuwiki.workspace.checkLinks",
"nuwiki.workspace.findOrphans",
];
pub(crate) async fn execute(
@@ -67,6 +70,16 @@ pub(crate) async fn execute(
"nuwiki.heading.removeLevel" => {
Ok(heading_change_level(backend, args, -1)?.map(CommandOutcome::Edit))
}
"nuwiki.toc.generate" => toc_generate(backend, args).map(|o| o.map(CommandOutcome::Edit)),
"nuwiki.links.generate" => {
links_generate(backend, args).map(|o| o.map(CommandOutcome::Edit))
}
"nuwiki.workspace.checkLinks" => {
workspace_check_links(backend, args).map(|o| o.map(CommandOutcome::Value))
}
"nuwiki.workspace.findOrphans" => {
workspace_find_orphans(backend, args).map(|o| o.map(CommandOutcome::Value))
}
other => Err(format!("unknown nuwiki command: {other}")),
}
}
@@ -151,6 +164,110 @@ fn heading_change_level(
))
}
#[derive(Deserialize)]
struct UriArg {
uri: Url,
}
fn parse_uri_arg(args: Vec<Value>) -> Result<UriArg, String> {
let raw = args
.into_iter()
.next()
.ok_or_else(|| "missing { uri } argument".to_string())?;
serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))
}
#[derive(Deserialize, Default)]
struct OptionalUriArg {
#[serde(default)]
uri: Option<Url>,
}
fn parse_optional_uri_arg(args: Vec<Value>) -> Result<OptionalUriArg, String> {
match args.into_iter().next() {
Some(v) => serde_json::from_value(v).map_err(|e| format!("invalid args: {e}")),
None => Ok(OptionalUriArg::default()),
}
}
fn toc_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
let p = parse_uri_arg(args)?;
let doc = match backend.documents.get(&p.uri) {
Some(d) => d,
None => return Ok(None),
};
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
Ok(ops::toc_edit(&doc.text, &doc.ast, &p.uri, utf8))
}
fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
let p = parse_uri_arg(args)?;
let doc = match backend.documents.get(&p.uri) {
Some(d) => d,
None => return Ok(None),
};
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
let wiki = match backend.wiki_for_uri(&p.uri) {
Some(w) => w,
None => return Ok(None),
};
let current_page = crate::index::page_name_from_uri(&p.uri, Some(&wiki.config.root));
let pages = {
let idx = wiki
.index
.read()
.map_err(|_| "index lock poisoned".to_string())?;
idx.page_names()
};
Ok(ops::links_edit(
&doc.text,
&doc.ast,
&p.uri,
&current_page,
&pages,
utf8,
))
}
fn workspace_check_links(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
let p = parse_optional_uri_arg(args)?;
let wiki = match p.uri.as_ref().and_then(|u| backend.wiki_for_uri(u)) {
Some(w) => w,
None => match backend.default_wiki() {
Some(w) => w,
None => return Ok(Some(serde_json::json!([]))),
},
};
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
let idx = wiki
.index
.read()
.map_err(|_| "index lock poisoned".to_string())?;
let broken = ops::collect_workspace_broken_links(backend, &idx, utf8);
Ok(Some(serde_json::to_value(broken).map_err(|e| {
format!("nuwiki.workspace.checkLinks: serialization failed — {e}")
})?))
}
fn workspace_find_orphans(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
let p = parse_optional_uri_arg(args)?;
let wiki = match p.uri.as_ref().and_then(|u| backend.wiki_for_uri(u)) {
Some(w) => w,
None => match backend.default_wiki() {
Some(w) => w,
None => return Ok(Some(serde_json::json!([]))),
},
};
let idx = wiki
.index
.read()
.map_err(|_| "index lock poisoned".to_string())?;
let orphans = ops::find_orphans(&idx);
Ok(Some(serde_json::to_value(orphans).map_err(|e| {
format!("nuwiki.workspace.findOrphans: serialization failed — {e}")
})?))
}
// ===== Pure operations =====
pub mod ops {
@@ -422,4 +539,289 @@ pub mod ops {
let _ = col;
None
}
// ===== Phase 15: TOC + Links generation, workspace queries =====
use serde::Serialize;
pub const TOC_HEADING: &str = "Contents";
pub const LINKS_HEADING: &str = "Generated Links";
use crate::diagnostics::classify_outgoing;
use crate::edits::text_edit_insert;
use crate::index::WorkspaceIndex;
use nuwiki_core::ast::Position as AstPosition;
/// One heading entry used to render the TOC.
struct TocItem {
level: u8,
title: String,
anchor: String,
}
/// Build a heading + nested list TOC for the current document. The TOC
/// itself (any existing `= Contents =` heading on the page) is skipped
/// so re-generation is idempotent.
pub fn build_toc_text(items: &[(u8, String, String)], heading_name: &str) -> String {
let mut out = String::new();
out.push_str("= ");
out.push_str(heading_name);
out.push_str(" =\n");
if items.is_empty() {
return out;
}
let min_level = items.iter().map(|(l, _, _)| *l).min().unwrap_or(1);
for (level, title, anchor) in items {
let depth = level.saturating_sub(min_level) as usize;
for _ in 0..depth {
out.push_str(" ");
}
out.push_str("- [[#");
out.push_str(anchor);
out.push('|');
out.push_str(title);
out.push_str("]]\n");
}
out
}
/// Build a flat list of wikilinks to every page (sorted), optionally
/// excluding `current_page`.
pub fn build_links_text(pages: &[String], heading_name: &str, exclude: Option<&str>) -> String {
let mut out = String::new();
out.push_str("= ");
out.push_str(heading_name);
out.push_str(" =\n");
for name in pages {
if Some(name.as_str()) == exclude {
continue;
}
out.push_str("- [[");
out.push_str(name);
out.push_str("]]\n");
}
out
}
/// Locate an existing section whose level-1 heading matches
/// `heading_name` case-insensitively. Returns the offset range covering
/// `[heading start .. (immediately-following list end | heading end)]`
/// plus the start `AstPosition` for emitting an LSP range from a
/// synthesised span.
pub fn find_section_range(
ast: &DocumentNode,
heading_name: &str,
) -> Option<(AstPosition, AstPosition)> {
let needle = heading_name.to_ascii_lowercase();
for (i, block) in ast.children.iter().enumerate() {
let BlockNode::Heading(h) = block else {
continue;
};
if h.level != 1 {
continue;
}
let title = heading_title(h).to_ascii_lowercase();
if title != needle {
continue;
}
let start = h.span.start;
let end = match ast.children.get(i + 1) {
Some(BlockNode::List(list)) => list.span.end,
_ => h.span.end,
};
return Some((start, end));
}
None
}
fn heading_title(h: &nuwiki_core::ast::HeadingNode) -> String {
crate::diagnostics::heading_text(&h.children)
}
fn collect_toc_items(ast: &DocumentNode, toc_heading: &str) -> Vec<TocItem> {
let mut out = Vec::new();
let needle = toc_heading.to_ascii_lowercase();
for block in &ast.children {
let BlockNode::Heading(h) = block else {
continue;
};
let title = heading_title(h);
if h.level == 1 && title.to_ascii_lowercase() == needle {
// skip the TOC's own heading
continue;
}
let anchor = crate::index::slugify(&title);
out.push(TocItem {
level: h.level,
title,
anchor,
});
}
out
}
/// Produce a `WorkspaceEdit` that replaces or inserts the TOC for the
/// given document. Returns `None` if the document has no headings at
/// all (other than possibly a pre-existing TOC heading).
pub fn toc_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
utf8: bool,
) -> Option<WorkspaceEdit> {
let items = collect_toc_items(ast, TOC_HEADING);
if items.is_empty() {
return None;
}
let triples: Vec<(u8, String, String)> = items
.into_iter()
.map(|it| (it.level, it.title, it.anchor))
.collect();
let new_text = build_toc_text(&triples, TOC_HEADING);
let mut b = WorkspaceEditBuilder::new();
match find_section_range(ast, TOC_HEADING) {
Some((start, end)) => {
let span = Span::new(start, end);
b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8));
}
None => {
let with_sep = format!("{new_text}\n");
b.edit(
uri.clone(),
text_edit_insert(
LspPosition {
line: 0,
character: 0,
},
with_sep,
),
);
}
}
Some(b.build())
}
/// Produce a `WorkspaceEdit` that replaces or inserts the
/// auto-generated links section for the given document.
pub fn links_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
current_page: &str,
all_pages: &[String],
utf8: bool,
) -> Option<WorkspaceEdit> {
if all_pages.is_empty() {
return None;
}
let new_text = build_links_text(all_pages, LINKS_HEADING, Some(current_page));
let mut b = WorkspaceEditBuilder::new();
match find_section_range(ast, LINKS_HEADING) {
Some((start, end)) => {
let span = Span::new(start, end);
b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8));
}
None => {
let with_sep = format!("{new_text}\n");
b.edit(
uri.clone(),
text_edit_insert(
LspPosition {
line: 0,
character: 0,
},
with_sep,
),
);
}
}
Some(b.build())
}
// ===== Workspace queries =====
#[derive(Debug, Serialize)]
pub struct BrokenLinkEntry {
pub uri: Url,
pub range: tower_lsp::lsp_types::Range,
pub kind: &'static str,
pub message: String,
}
/// Walk every indexed page and classify each outgoing link against the
/// workspace index. Open documents (held in `backend.documents`) use
/// live text for LSP range conversion; closed pages fall back to the
/// stored span coordinates verbatim (they're already in line/column form).
pub(crate) fn collect_workspace_broken_links(
backend: &Backend,
index: &WorkspaceIndex,
utf8: bool,
) -> Vec<BrokenLinkEntry> {
let mut out = Vec::new();
for page in index.pages_by_uri.values() {
for link in &page.outgoing {
let Some(broken) = classify_outgoing(link, &page.uri, index, &page.name) else {
continue;
};
let range = match backend.documents.get(&page.uri) {
Some(doc) => crate::to_lsp_range(&link.span, &doc.text, utf8),
None => no_text_range(&link.span),
};
out.push(BrokenLinkEntry {
uri: page.uri.clone(),
range,
kind: broken.tag(),
message: broken.message(),
});
}
}
out.sort_by(|a, b| {
(a.uri.as_str(), a.range.start.line, a.range.start.character).cmp(&(
b.uri.as_str(),
b.range.start.line,
b.range.start.character,
))
});
out
}
#[derive(Debug, Serialize)]
pub struct OrphanEntry {
pub uri: Url,
pub name: String,
}
/// A page is an orphan when no other indexed page links to it. The
/// current convention is to surface every orphan and let the client
/// filter out the index page if it wants to.
pub fn find_orphans(index: &WorkspaceIndex) -> Vec<OrphanEntry> {
let mut out: Vec<OrphanEntry> = index
.pages_by_uri
.values()
.filter(|p| index.backlinks_for(&p.name).is_empty())
.map(|p| OrphanEntry {
uri: p.uri.clone(),
name: p.name.clone(),
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn no_text_range(span: &Span) -> tower_lsp::lsp_types::Range {
tower_lsp::lsp_types::Range {
start: LspPosition {
line: span.start.line,
character: span.start.column,
},
end: LspPosition {
line: span.end.line,
character: span.end.column,
},
}
}
/// Re-exported AST link walker so tests can drive it without depending
/// on the diagnostics module's internals.
pub use crate::diagnostics::collect_wiki_links;
}
+382
View File
@@ -0,0 +1,382 @@
//! 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()?;
let page = index.page_by_name(path);
match page {
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.headings.iter().any(|h| h.anchor == anchor) || 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_by_name(path) {
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()?;
// OutgoingLink doesn't carry `is_absolute`; recover from the
// string itself — `//path` and any other root-anchored form
// start with `/`. Anything else is relative to the source URI's
// parent directory.
let candidate = PathBuf::from(path);
let resolved = if candidate.is_absolute() {
candidate
} else if let Ok(base) = source_uri.to_file_path() {
match base.parent() {
Some(dir) => dir.join(&candidate),
None => candidate,
}
} else if let Some(root) = index.root.as_ref() {
root.join(&candidate)
} else {
return None;
};
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
}
+28 -11
View File
@@ -23,6 +23,7 @@
pub mod commands;
pub mod config;
pub mod diagnostics;
pub mod edits;
pub mod index;
pub mod nav;
@@ -141,9 +142,8 @@ impl Backend {
wikis.iter().find(|w| w.id == id).cloned()
}
/// Scaffolded for Phase 12+ commands that operate on the default wiki
/// without a URI in hand (workspace-level operations).
#[allow(dead_code)]
/// Used by Phase 15 workspace-level commands (`checkLinks`, `findOrphans`)
/// when no URI is supplied — they fall back to the first registered wiki.
fn default_wiki(&self) -> Option<Wiki> {
let wikis = self.wikis.read().ok()?;
wikis.first().cloned()
@@ -236,13 +236,14 @@ impl Backend {
collect_diagnostics(
&ast,
&text,
Some(&uri),
Some(&idx_guard),
page_name.as_deref(),
&cfg.diagnostic,
utf8,
)
} else {
collect_diagnostics(&ast, &text, None, None, &cfg.diagnostic, utf8)
collect_diagnostics(&ast, &text, Some(&uri), None, None, &cfg.diagnostic, utf8)
};
diags
};
@@ -786,7 +787,15 @@ fn utf16_column(text: &str, line: u32, byte_col: u32) -> u32 {
/// should use `collect_diagnostics` so they can opt into link-health
/// (Phase 15) and other sources.
pub fn ast_diagnostics(ast: &DocumentNode, text: &str, utf8: bool) -> Vec<Diagnostic> {
collect_diagnostics(ast, text, None, None, &DiagnosticConfig::default(), utf8)
collect_diagnostics(
ast,
text,
None,
None,
None,
&DiagnosticConfig::default(),
utf8,
)
}
/// Composable diagnostic collector (P11 §12.2.4).
@@ -794,13 +803,12 @@ pub fn ast_diagnostics(ast: &DocumentNode, text: &str, utf8: bool) -> Vec<Diagno
/// Sources, each gated by `cfg`:
///
/// 1. Parse errors (always on).
/// 2. Broken-link warnings — wired up in Phase 15. For Phase 11 the
/// `index` / `page_name` / `cfg.link_severity` arguments are
/// accepted but the source is a stub returning no diagnostics, so
/// handlers can already pass them in without further refactor.
/// 2. Broken-link warnings (Phase 15) — emitted when an index, source
/// page name, and `link_severity != Off` are all available.
pub fn collect_diagnostics(
ast: &DocumentNode,
text: &str,
uri: Option<&Url>,
index: Option<&WorkspaceIndex>,
page_name: Option<&str>,
cfg: &DiagnosticConfig,
@@ -808,8 +816,17 @@ pub fn collect_diagnostics(
) -> Vec<Diagnostic> {
let mut out = Vec::new();
walk_blocks_for_errors(&ast.children, text, utf8, &mut out);
// Phase 15 will implement link-health here.
let _ = (index, page_name, cfg);
if let (Some(idx), Some(name)) = (index, page_name) {
out.extend(diagnostics::link_health(
ast,
text,
uri,
idx,
name,
cfg.link_severity,
utf8,
));
}
out
}