diff --git a/README.md b/README.md index 8c2a07a..cb12bad 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ See [`SPEC.md`](./SPEC.md) for the full project specification. | 12 | Tags | ✅ done | | 13 | Workspace edits + executeCommand | ✅ done | | 14 | List & table edit commands | ✅ done (focused subset; table/list-renumber/colorize deferred) | -| 15 | Link health + TOC/index generation | ⏳ | +| 15 | Link health + TOC/index generation | ✅ done | | 16 | Diary | ⏳ | | 17 | HTML export commands | ⏳ | | 18 | Multi-wiki | ⏳ | diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 70fd358..041eeff 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -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) -> Result { + 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, +} + +fn parse_optional_uri_arg(args: Vec) -> Result { + 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) -> Result, 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) -> Result, 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) -> Result, 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) -> Result, 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 { + 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 { + 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 { + 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 { + 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 { + let mut out: Vec = 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; } diff --git a/crates/nuwiki-lsp/src/diagnostics.rs b/crates/nuwiki-lsp/src/diagnostics.rs new file mode 100644 index 0000000..5019c90 --- /dev/null +++ b/crates/nuwiki-lsp/src/diagnostics.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index 37951c7..8546fd8 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -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 { 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 { - 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, index: Option<&WorkspaceIndex>, page_name: Option<&str>, cfg: &DiagnosticConfig, @@ -808,8 +816,17 @@ pub fn collect_diagnostics( ) -> Vec { 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 } diff --git a/crates/nuwiki-lsp/tests/phase11_plumbing.rs b/crates/nuwiki-lsp/tests/phase11_plumbing.rs index 9b1ef3f..1baca73 100644 --- a/crates/nuwiki-lsp/tests/phase11_plumbing.rs +++ b/crates/nuwiki-lsp/tests/phase11_plumbing.rs @@ -241,20 +241,18 @@ fn collect_diagnostics_emits_one_per_error_node() { ], }; let cfg = DiagnosticConfig::default(); - let diags = collect_diagnostics(&doc, "abc ok", None, None, &cfg, true); + let diags = collect_diagnostics(&doc, "abc ok", None, None, None, &cfg, true); assert_eq!(diags.len(), 1); assert_eq!(diags[0].message, "broken"); } #[test] -fn collect_diagnostics_link_health_stubs_to_empty_in_phase_11() { - // With an index supplied but the link-health source not yet wired in, - // we should still get zero link-related diagnostics (Phase 15 wires - // them in). +fn collect_diagnostics_link_health_emits_nothing_without_index() { + // No index → link-health source is skipped, even when severity is on. let doc = DocumentNode::default(); let cfg = DiagnosticConfig { link_severity: LinkSeverity::Error, }; - let diags = collect_diagnostics(&doc, "", None, Some("Home"), &cfg, true); + let diags = collect_diagnostics(&doc, "", None, None, Some("Home"), &cfg, true); assert!(diags.is_empty()); } diff --git a/crates/nuwiki-lsp/tests/phase15_link_health.rs b/crates/nuwiki-lsp/tests/phase15_link_health.rs new file mode 100644 index 0000000..0a37736 --- /dev/null +++ b/crates/nuwiki-lsp/tests/phase15_link_health.rs @@ -0,0 +1,480 @@ +//! Phase 15: link-health diagnostics + TOC/links/orphans queries. + +use std::path::PathBuf; + +use nuwiki_core::syntax::vimwiki::VimwikiSyntax; +use nuwiki_core::syntax::SyntaxPlugin; +use nuwiki_lsp::commands::ops; +use nuwiki_lsp::config::LinkSeverity; +use nuwiki_lsp::diagnostics::{self, BrokenLinkKind}; +use nuwiki_lsp::index::WorkspaceIndex; +use tower_lsp::lsp_types::{DiagnosticSeverity, Url}; + +fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { + VimwikiSyntax::new().parse(src) +} + +fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex { + let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root))); + for (name, src) in pages { + let ast = parse(src); + let path = format!("{root}/{name}.wiki"); + let uri = Url::from_file_path(&path).unwrap(); + idx.upsert(uri, &ast); + } + idx +} + +fn home_uri(root: &str) -> Url { + Url::from_file_path(format!("{root}/Home.wiki")).unwrap() +} + +// ===== severity_to_lsp ===== + +#[test] +fn severity_off_returns_none() { + assert!(diagnostics::severity_to_lsp(LinkSeverity::Off).is_none()); +} + +#[test] +fn severity_levels_round_trip() { + assert_eq!( + diagnostics::severity_to_lsp(LinkSeverity::Hint), + Some(DiagnosticSeverity::HINT) + ); + assert_eq!( + diagnostics::severity_to_lsp(LinkSeverity::Warning), + Some(DiagnosticSeverity::WARNING) + ); + assert_eq!( + diagnostics::severity_to_lsp(LinkSeverity::Error), + Some(DiagnosticSeverity::ERROR) + ); +} + +// ===== link_health: wiki targets ===== + +#[test] +fn link_to_missing_page_warns() { + let root = "/tmp/lh1"; + let idx = build_index(root, &[("Home", "[[GhostPage]]\n")]); + let src = "[[GhostPage]]\n"; + let ast = parse(src); + let uri = home_uri(root); + let diags = diagnostics::link_health( + &ast, + src, + Some(&uri), + &idx, + "Home", + LinkSeverity::Warning, + true, + ); + assert_eq!(diags.len(), 1); + assert!(diags[0].message.contains("GhostPage")); + assert_eq!(diags[0].severity, Some(DiagnosticSeverity::WARNING)); +} + +#[test] +fn link_to_existing_page_silent() { + let root = "/tmp/lh2"; + let idx = build_index(root, &[("Home", "[[Other]]\n"), ("Other", "= Other =\n")]); + let src = "[[Other]]\n"; + let ast = parse(src); + let diags = diagnostics::link_health( + &ast, + src, + Some(&home_uri(root)), + &idx, + "Home", + LinkSeverity::Warning, + true, + ); + assert!(diags.is_empty(), "got: {:?}", diags); +} + +#[test] +fn link_with_valid_anchor_silent() { + let root = "/tmp/lh3"; + let idx = build_index( + root, + &[ + ("Home", "[[Target#section-one]]\n"), + ("Target", "= Target =\n== Section One ==\n"), + ], + ); + let src = "[[Target#section-one]]\n"; + let ast = parse(src); + let diags = diagnostics::link_health( + &ast, + src, + Some(&home_uri(root)), + &idx, + "Home", + LinkSeverity::Warning, + true, + ); + assert!(diags.is_empty(), "got: {:?}", diags); +} + +#[test] +fn link_with_missing_anchor_warns() { + let root = "/tmp/lh4"; + let idx = build_index( + root, + &[ + ("Home", "[[Target#ghost-anchor]]\n"), + ("Target", "= Target =\n"), + ], + ); + let src = "[[Target#ghost-anchor]]\n"; + let ast = parse(src); + let diags = diagnostics::link_health( + &ast, + src, + Some(&home_uri(root)), + &idx, + "Home", + LinkSeverity::Warning, + true, + ); + assert_eq!(diags.len(), 1); + assert!(diags[0].message.to_lowercase().contains("anchor")); +} + +#[test] +fn anchor_only_link_resolves_against_current_page() { + let root = "/tmp/lh5"; + let idx = build_index(root, &[("Home", "= Home =\n== Intro ==\n[[#intro]]\n")]); + let src = "= Home =\n== Intro ==\n[[#intro]]\n"; + let ast = parse(src); + let diags = diagnostics::link_health( + &ast, + src, + Some(&home_uri(root)), + &idx, + "Home", + LinkSeverity::Warning, + true, + ); + assert!(diags.is_empty(), "got: {:?}", diags); +} + +#[test] +fn anchor_only_link_to_missing_anchor_warns() { + let root = "/tmp/lh6"; + let idx = build_index(root, &[("Home", "[[#nowhere]]\n")]); + let src = "[[#nowhere]]\n"; + let ast = parse(src); + let diags = diagnostics::link_health( + &ast, + src, + Some(&home_uri(root)), + &idx, + "Home", + LinkSeverity::Warning, + true, + ); + assert_eq!(diags.len(), 1); + assert!(diags[0].message.contains("nowhere")); +} + +#[test] +fn link_severity_off_emits_nothing() { + let root = "/tmp/lh7"; + let idx = build_index(root, &[("Home", "[[GhostPage]]\n")]); + let src = "[[GhostPage]]\n"; + let ast = parse(src); + let diags = diagnostics::link_health( + &ast, + src, + Some(&home_uri(root)), + &idx, + "Home", + LinkSeverity::Off, + true, + ); + assert!(diags.is_empty()); +} + +#[test] +fn raw_url_never_emits_diagnostic() { + let root = "/tmp/lh8"; + let idx = build_index(root, &[("Home", "https://example.com\n")]); + let src = "https://example.com\n"; + let ast = parse(src); + let diags = diagnostics::link_health( + &ast, + src, + Some(&home_uri(root)), + &idx, + "Home", + LinkSeverity::Error, + true, + ); + assert!(diags.is_empty()); +} + +#[test] +fn tag_as_anchor_resolves() { + let root = "/tmp/lh9"; + let idx = build_index( + root, + &[ + ("Home", "[[Notes#release]]\n"), + ("Notes", "= Notes =\n:release:\n"), + ], + ); + let src = "[[Notes#release]]\n"; + let ast = parse(src); + let diags = diagnostics::link_health( + &ast, + src, + Some(&home_uri(root)), + &idx, + "Home", + LinkSeverity::Warning, + true, + ); + assert!(diags.is_empty(), "got: {:?}", diags); +} + +// ===== BrokenLinkKind classification ===== + +#[test] +fn classify_missing_page() { + let kind = BrokenLinkKind::MissingPage("X".into()); + assert_eq!(kind.tag(), "missing-page"); + assert!(kind.message().contains("X")); +} + +#[test] +fn classify_missing_anchor() { + let kind = BrokenLinkKind::MissingAnchor { + page: "P".into(), + anchor: "a".into(), + }; + assert_eq!(kind.tag(), "missing-anchor"); + assert!(kind.message().contains("a")); + assert!(kind.message().contains("P")); +} + +#[test] +fn classify_missing_file() { + let kind = BrokenLinkKind::MissingFile(PathBuf::from("/tmp/nope.txt")); + assert_eq!(kind.tag(), "missing-file"); + assert!(kind.message().contains("nope.txt")); +} + +// ===== heading_text ===== + +#[test] +fn heading_text_strips_formatting() { + let src = "= *Bold* heading =\n"; + let ast = parse(src); + let h = match &ast.children[0] { + nuwiki_core::ast::BlockNode::Heading(h) => h, + _ => panic!("expected heading"), + }; + let t = diagnostics::heading_text(&h.children); + assert!(t.contains("Bold")); + assert!(t.contains("heading")); +} + +// ===== TOC text generation ===== + +#[test] +fn build_toc_text_nests_by_level() { + let items = vec![ + (1u8, "Top".into(), "top".into()), + (2u8, "Sub".into(), "sub".into()), + (1u8, "Other".into(), "other".into()), + ]; + let out = ops::build_toc_text(&items, "Contents"); + assert!(out.starts_with("= Contents =\n")); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines[0], "= Contents ="); + assert_eq!(lines[1], "- [[#top|Top]]"); + assert_eq!(lines[2], " - [[#sub|Sub]]"); + assert_eq!(lines[3], "- [[#other|Other]]"); +} + +#[test] +fn build_toc_text_with_no_headings_is_just_the_heading() { + let out = ops::build_toc_text(&[], "Contents"); + assert_eq!(out, "= Contents =\n"); +} + +// ===== Links text generation ===== + +#[test] +fn build_links_text_excludes_current_page() { + let pages = vec!["A".to_string(), "Home".to_string(), "B".to_string()]; + let out = ops::build_links_text(&pages, "Generated Links", Some("Home")); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines[0], "= Generated Links ="); + assert!(lines.contains(&"- [[A]]")); + assert!(lines.contains(&"- [[B]]")); + assert!(!lines.iter().any(|l| l.contains("Home"))); +} + +#[test] +fn build_links_text_no_excludes_includes_all() { + let pages = vec!["A".to_string(), "B".to_string()]; + let out = ops::build_links_text(&pages, "Generated Links", None); + assert!(out.contains("[[A]]")); + assert!(out.contains("[[B]]")); +} + +// ===== find_section_range ===== + +#[test] +fn find_section_range_matches_case_insensitive() { + let src = "= contents =\n- [[A]]\n- [[B]]\n"; + let ast = parse(src); + let (_, _) = ops::find_section_range(&ast, "Contents").expect("found"); +} + +#[test] +fn find_section_range_misses_when_absent() { + let src = "= Welcome =\n"; + let ast = parse(src); + assert!(ops::find_section_range(&ast, "Contents").is_none()); +} + +#[test] +fn find_section_range_only_matches_h1() { + let src = "== Contents ==\n- [[A]]\n"; + let ast = parse(src); + // h2 — should not match. + assert!(ops::find_section_range(&ast, "Contents").is_none()); +} + +// ===== toc_edit ===== + +#[test] +fn toc_edit_inserts_at_top_when_no_existing_toc() { + let src = "= One =\n== Two ==\n"; + let ast = parse(src); + let uri = Url::parse("file:///tmp/page.wiki").unwrap(); + let edit = ops::toc_edit(src, &ast, &uri, true).expect("got an edit"); + let changes = edit.changes.expect("changes map"); + let edits = &changes[&uri]; + assert_eq!(edits.len(), 1); + let te = &edits[0]; + // Insertion at position 0,0 → zero-width range. + assert_eq!(te.range.start, te.range.end); + assert!(te.new_text.starts_with("= Contents =\n")); + assert!(te.new_text.contains("[[#one|One]]")); +} + +#[test] +fn toc_edit_replaces_existing_toc() { + let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n"; + let ast = parse(src); + let uri = Url::parse("file:///tmp/page.wiki").unwrap(); + let edit = ops::toc_edit(src, &ast, &uri, true).expect("got an edit"); + let changes = edit.changes.expect("changes map"); + let edits = &changes[&uri]; + let te = &edits[0]; + assert!(te.new_text.contains("[[#real|Real]]")); + assert!(!te.new_text.contains("Stale")); + // The replacement range must start at line 0. + assert_eq!(te.range.start.line, 0); +} + +#[test] +fn toc_edit_returns_none_for_empty_doc() { + let src = ""; + let ast = parse(src); + let uri = Url::parse("file:///tmp/empty.wiki").unwrap(); + assert!(ops::toc_edit(src, &ast, &uri, true).is_none()); +} + +// ===== links_edit ===== + +#[test] +fn links_edit_inserts_when_section_absent() { + let src = "Hello\n"; + let ast = parse(src); + let uri = Url::parse("file:///tmp/page.wiki").unwrap(); + let pages = vec!["A".into(), "B".into(), "Home".into()]; + let edit = ops::links_edit(src, &ast, &uri, "Home", &pages, true).expect("edit"); + let te = &edit.changes.unwrap()[&uri][0]; + assert!(te.new_text.contains("[[A]]")); + assert!(te.new_text.contains("[[B]]")); + assert!(!te.new_text.contains("[[Home]]")); +} + +#[test] +fn links_edit_replaces_when_section_present() { + let src = "= Generated Links =\n- [[Stale]]\n\n= Real =\n"; + let ast = parse(src); + let uri = Url::parse("file:///tmp/page.wiki").unwrap(); + let pages = vec!["Fresh".into()]; + let edit = ops::links_edit(src, &ast, &uri, "Home", &pages, true).expect("edit"); + let te = &edit.changes.unwrap()[&uri][0]; + assert!(te.new_text.contains("[[Fresh]]")); + assert!(!te.new_text.contains("Stale")); + assert_eq!(te.range.start.line, 0); +} + +#[test] +fn links_edit_returns_none_for_empty_page_list() { + let src = "Hi\n"; + let ast = parse(src); + let uri = Url::parse("file:///tmp/page.wiki").unwrap(); + assert!(ops::links_edit(src, &ast, &uri, "Home", &[], true).is_none()); +} + +// ===== find_orphans ===== + +#[test] +fn find_orphans_lists_pages_with_no_backlinks() { + let root = "/tmp/orph"; + let idx = build_index( + root, + &[ + ("Home", "[[A]]\n"), + ("A", "= A =\n"), + ("B", "= B =\n"), // not linked from anywhere + ], + ); + let orphans = ops::find_orphans(&idx); + let names: Vec<&str> = orphans.iter().map(|o| o.name.as_str()).collect(); + // Home + B have no backlinks. A is linked from Home. + assert!(names.contains(&"Home")); + assert!(names.contains(&"B")); + assert!(!names.contains(&"A")); +} + +// ===== collect_wiki_links (AST walker) ===== + +#[test] +fn collect_wiki_links_finds_nested() { + let src = "= [[A]] =\n*[[B]]* and [[C|desc]]\n- [[D]]\n"; + let ast = parse(src); + let links = nuwiki_lsp::diagnostics::collect_wiki_links(&ast); + let paths: Vec<&str> = links + .iter() + .filter_map(|l| l.target.path.as_deref()) + .collect(); + for expected in ["A", "B", "C", "D"] { + assert!(paths.contains(&expected), "missing {expected}"); + } +} + +// ===== COMMANDS completeness ===== + +#[test] +fn commands_list_includes_phase15_entries() { + let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); + for name in [ + "nuwiki.toc.generate", + "nuwiki.links.generate", + "nuwiki.workspace.checkLinks", + "nuwiki.workspace.findOrphans", + ] { + assert!(names.contains(&name), "missing: {name}"); + } +}