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
+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
}