From 859d69e4fed464154fe8592c41fe8a4155a5831f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Tue, 26 May 2026 22:29:30 -0300 Subject: [PATCH] fix(lsp): match wikilink anchors against raw heading text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[[Page#Heading text]]` parsed the anchor as the literal string after `#`, but the index stored each heading's anchor as `slugify(title)`. The two never compared equal, so every TOC-style anchor link (`[[#1. Topologia atual]]`, etc.) was reported as a broken anchor and goto-definition couldn't jump to the right heading either. Slugify the request before comparing — `slugify` is idempotent on valid-slug input, so the same code path now accepts both `[[Page#some-heading]]` (slug form) and `[[Page#Some Heading]]` (raw text). Applied to diagnostics, navigation, and hover preview so all three agree on what "an anchor matches a heading" means. Co-Authored-By: Claude Sonnet 4.6 --- crates/nuwiki-lsp/src/diagnostics.rs | 3 +- crates/nuwiki-lsp/src/index.rs | 12 ++++++ crates/nuwiki-lsp/src/lib.rs | 4 +- crates/nuwiki-lsp/tests/link_health.rs | 54 ++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/crates/nuwiki-lsp/src/diagnostics.rs b/crates/nuwiki-lsp/src/diagnostics.rs index d9228ce..adc54b8 100644 --- a/crates/nuwiki-lsp/src/diagnostics.rs +++ b/crates/nuwiki-lsp/src/diagnostics.rs @@ -212,7 +212,8 @@ pub fn classify_link( } 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) + page.find_heading_by_anchor(anchor).is_some() + || page.tags.iter().any(|t| t.name == anchor) } /// Same classification as [`classify_link`] but driven from the cached diff --git a/crates/nuwiki-lsp/src/index.rs b/crates/nuwiki-lsp/src/index.rs index b198d5d..d5f7f21 100644 --- a/crates/nuwiki-lsp/src/index.rs +++ b/crates/nuwiki-lsp/src/index.rs @@ -62,6 +62,18 @@ pub struct HeadingInfo { pub span: Span, } +impl IndexedPage { + /// Find a heading whose anchor matches `request`. The on-disk wikilink + /// syntax lets users write either the slug (`[[Page#some-heading]]`) or + /// the raw heading text (`[[Page#Some Heading]]`). `slugify` is + /// idempotent on valid slug input, so applying it to the request + /// before comparison handles both forms in a single pass. + pub fn find_heading_by_anchor(&self, request: &str) -> Option<&HeadingInfo> { + let needle = slugify(request); + self.headings.iter().find(|h| h.anchor == needle) + } +} + #[derive(Debug, Clone)] pub struct OutgoingLink { /// Resolved target page name (for `LinkKind::Wiki`) or `None` for kinds diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index 632017f..0bf5ed6 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -1386,7 +1386,7 @@ fn heading_range_in(idx: &WorkspaceIndex, uri: &Url, anchor: &str, _utf8: bool) let page = idx.page(uri)?; // Headings win over tags when both match — same precedence as // vimwiki: heading anchors are the "canonical" target shape. - if let Some(h) = page.headings.iter().find(|h| h.anchor == anchor) { + if let Some(h) = page.find_heading_by_anchor(anchor) { return Some(span_to_lsp_range_no_text(&h.span)); } // Phase 12: tags-as-anchors. `[[Page#tag-name]]` lands on the @@ -1434,7 +1434,7 @@ fn preview_for(page: &IndexedPage, anchor: Option<&str>) -> String { buf.push_str(title); buf.push_str("**\n\n"); if let Some(anchor) = anchor { - if let Some(h) = page.headings.iter().find(|h| h.anchor == anchor) { + if let Some(h) = page.find_heading_by_anchor(anchor) { buf.push_str(&format!("§ {}\n", h.title)); } else { buf.push_str(&format!("(anchor `{anchor}` not found)\n")); diff --git a/crates/nuwiki-lsp/tests/link_health.rs b/crates/nuwiki-lsp/tests/link_health.rs index 3c626af..f086728 100644 --- a/crates/nuwiki-lsp/tests/link_health.rs +++ b/crates/nuwiki-lsp/tests/link_health.rs @@ -466,6 +466,60 @@ fn collect_wiki_links_finds_nested() { } } +// ===== Anchor matching against raw heading text ===== + +#[test] +fn anchor_matches_raw_heading_text() { + // Users write `[[Page#Some Heading]]` (raw text), the index keys by + // slugify form. Slugifying the request before comparison makes both + // shapes work. + let root = "/tmp/anchor1"; + let idx = build_index( + root, + &[ + ("Home", "[[Target#Some Heading]]\n"), + ("Target", "= Target =\n== Some Heading ==\n"), + ], + ); + let src = "[[Target#Some Heading]]\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_matches_unicode_heading() { + // Real-world content: heading with em dash and accented chars. + let root = "/tmp/anchor2"; + let idx = build_index( + root, + &[ + ("Home", "[[Target#Homelab — VLANs]]\n"), + ("Target", "= Target =\n== Homelab — VLANs ==\n"), + ], + ); + let src = "[[Target#Homelab — VLANs]]\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); +} + // ===== COMMANDS completeness ===== #[test]