phase 15: link health + TOC/links/orphans
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:
@@ -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,
|
||||
¤t_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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user