fix(lsp): match wikilink anchors against raw heading text
`[[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 <noreply@anthropic.com>
This commit is contained in:
@@ -212,7 +212,8 @@ pub fn classify_link(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn anchor_resolves_on(page: &crate::index::IndexedPage, anchor: &str) -> bool {
|
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
|
/// Same classification as [`classify_link`] but driven from the cached
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ pub struct HeadingInfo {
|
|||||||
pub span: Span,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct OutgoingLink {
|
pub struct OutgoingLink {
|
||||||
/// Resolved target page name (for `LinkKind::Wiki`) or `None` for kinds
|
/// Resolved target page name (for `LinkKind::Wiki`) or `None` for kinds
|
||||||
|
|||||||
@@ -1386,7 +1386,7 @@ fn heading_range_in(idx: &WorkspaceIndex, uri: &Url, anchor: &str, _utf8: bool)
|
|||||||
let page = idx.page(uri)?;
|
let page = idx.page(uri)?;
|
||||||
// Headings win over tags when both match — same precedence as
|
// Headings win over tags when both match — same precedence as
|
||||||
// vimwiki: heading anchors are the "canonical" target shape.
|
// 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));
|
return Some(span_to_lsp_range_no_text(&h.span));
|
||||||
}
|
}
|
||||||
// Phase 12: tags-as-anchors. `[[Page#tag-name]]` lands on the
|
// 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(title);
|
||||||
buf.push_str("**\n\n");
|
buf.push_str("**\n\n");
|
||||||
if let Some(anchor) = anchor {
|
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));
|
buf.push_str(&format!("§ {}\n", h.title));
|
||||||
} else {
|
} else {
|
||||||
buf.push_str(&format!("(anchor `{anchor}` not found)\n"));
|
buf.push_str(&format!("(anchor `{anchor}` not found)\n"));
|
||||||
|
|||||||
@@ -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 =====
|
// ===== COMMANDS completeness =====
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user