From 2aec51274ebe35c522faf8faa81e223031af678f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Tue, 26 May 2026 22:00:03 -0300 Subject: [PATCH] fix(lsp): resolve wikilinks that include the .wiki file extension `[[page.wiki]]` should resolve identically to `[[page]]`. The index keyed by stem, so links with the explicit extension fell through to the broken-link diagnostic and to-page navigation failed. Strip the configured extension before every page lookup (resolve, definition, backlinks, classify_link, classify_outgoing) and stop appending the extension in synthesise_page_uri when it's already present. Co-Authored-By: Claude Sonnet 4.6 --- crates/nuwiki-lsp/src/diagnostics.rs | 6 ++-- crates/nuwiki-lsp/src/index.rs | 53 +++++++++++++++++++++++++--- crates/nuwiki-lsp/src/lib.rs | 23 +++++++----- crates/nuwiki-lsp/src/wiki.rs | 3 +- 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/crates/nuwiki-lsp/src/diagnostics.rs b/crates/nuwiki-lsp/src/diagnostics.rs index 5019c90..d9228ce 100644 --- a/crates/nuwiki-lsp/src/diagnostics.rs +++ b/crates/nuwiki-lsp/src/diagnostics.rs @@ -166,7 +166,8 @@ pub fn classify_link( match link.target.kind { LinkKind::Wiki => { let path = link.target.path.as_deref()?; - let page = index.page_by_name(path); + let normalized = index.strip_link_extension(path); + let page = index.page_by_name(normalized); match page { None => Some(BrokenLinkKind::MissingPage(path.to_string())), Some(page) => { @@ -227,7 +228,8 @@ pub fn classify_outgoing( match link.kind { LinkKind::Wiki => { let path = link.target_page.as_deref()?; - match index.page_by_name(path) { + let normalized = index.strip_link_extension(path); + match index.page_by_name(normalized) { None => Some(BrokenLinkKind::MissingPage(path.to_string())), Some(page) => { if let Some(anchor) = &link.anchor { diff --git a/crates/nuwiki-lsp/src/index.rs b/crates/nuwiki-lsp/src/index.rs index 5a2e4e8..b198d5d 100644 --- a/crates/nuwiki-lsp/src/index.rs +++ b/crates/nuwiki-lsp/src/index.rs @@ -96,6 +96,10 @@ pub struct WorkspaceIndex { /// Mirrors `WikiConfig::diary_rel_path`; stored here so `upsert` can /// classify each indexed URI without a back-reference to the config. pub diary_rel_path: Option, + /// File extension for this wiki's pages (e.g. `".wiki"` or `"wiki"`). + /// Used to normalise link targets that include the extension so that + /// `[[page.wiki]]` resolves the same way as `[[page]]`. + pub file_extension: Option, pub pages_by_uri: HashMap, pub pages_by_name: HashMap, pub backlinks: HashMap>, @@ -118,6 +122,19 @@ impl WorkspaceIndex { self } + pub fn with_file_extension(mut self, ext: Option) -> Self { + self.file_extension = ext; + self + } + + /// Strip the wiki's configured file extension from a link target path. + /// Handles both `"page.wiki"` → `"page"` and `"subdir/page.wiki"` → + /// `"subdir/page"`. Returns `path` unchanged when the extension doesn't + /// match or no extension is configured. + pub fn strip_link_extension<'a>(&self, path: &'a str) -> &'a str { + strip_wiki_extension(path, self.file_extension.as_deref()) + } + /// Insert or update an indexed page. Removes any prior indexing for /// the same URI first (handles renames and re-parses without leaking /// stale backlinks). @@ -142,10 +159,12 @@ impl WorkspaceIndex { }; index_blocks(&ast.children, &mut page); + let ext = self.file_extension.as_deref(); for link in &page.outgoing { if let Some(tgt) = &link.target_page { + let norm = strip_wiki_extension(tgt, ext); self.backlinks - .entry(tgt.clone()) + .entry(norm.to_string()) .or_default() .push(Backlink { source_uri: uri.clone(), @@ -199,10 +218,10 @@ impl WorkspaceIndex { /// needed for `AnchorOnly` resolution. pub fn resolve(&self, target: &LinkTarget, source_page: &str) -> Option<&Url> { match target.kind { - LinkKind::Wiki => target - .path - .as_deref() - .and_then(|p| self.pages_by_name.get(p)), + LinkKind::Wiki => target.path.as_deref().and_then(|p| { + let normalized = self.strip_link_extension(p); + self.pages_by_name.get(normalized) + }), LinkKind::AnchorOnly => self.pages_by_name.get(source_page), // Interwiki / Diary / File / Local / Raw aren't resolved // against the workspace index here. The handlers fall back to @@ -467,6 +486,30 @@ fn inline_to_text_into(inlines: &[InlineNode], out: &mut String) { // ===== Filesystem walking ===== +/// Strip a wiki file extension from a link target path without allocating. +/// +/// `file_extension` may be `".wiki"` (with leading dot) or `"wiki"` (without). +/// Returns `path` unchanged when no extension is configured, the extension is +/// empty, or the path doesn't end with `.{ext}`. +pub fn strip_wiki_extension<'a>(path: &'a str, file_extension: Option<&str>) -> &'a str { + let ext = match file_extension { + Some(e) => e.trim_start_matches('.'), + None => return path, + }; + if ext.is_empty() { + return path; + } + let dot_ext_len = ext.len() + 1; // +1 for the '.' + if path.len() > dot_ext_len + && path.ends_with(ext) + && path.as_bytes()[path.len() - dot_ext_len] == b'.' + { + &path[..path.len() - dot_ext_len] + } else { + path + } +} + /// Recursively collect every `.wiki` file under `root`, skipping dotfiles /// and dot-directories. pub fn walk_wiki_files(root: &Path) -> Vec { diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index 030b7f7..632017f 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -240,7 +240,8 @@ impl Backend { }; let path = target.path.as_deref()?; if let Ok(idx) = target_wiki.index.read() { - if let Some(uri) = idx.pages_by_name.get(path).cloned() { + let normalized = idx.strip_link_extension(path); + if let Some(uri) = idx.pages_by_name.get(normalized).cloned() { return Some(uri); } } @@ -711,7 +712,10 @@ impl LanguageServer for Backend { index::page_name_from_uri(uri, wiki.as_ref().map(|w| w.config.root.as_path())); let target_page = match nav::find_inline_at(&doc.ast, line, col) { Some(InlineNode::WikiLink(w)) => match w.target.kind { - nuwiki_core::ast::LinkKind::Wiki => w.target.path.clone(), + nuwiki_core::ast::LinkKind::Wiki => w.target.path.as_ref().map(|p| { + let ext = wiki.as_ref().map(|w| w.config.file_extension.as_str()); + index::strip_wiki_extension(p, ext).to_string() + }), nuwiki_core::ast::LinkKind::AnchorOnly => Some(source_page.clone()), _ => None, }, @@ -977,12 +981,15 @@ fn synthesise_page_uri(cfg: &crate::config::WikiConfig, path: &str) -> Option Self { let index = WorkspaceIndex::new(Some(config.root.clone())) - .with_diary_rel_path(Some(config.diary_rel_path.clone())); + .with_diary_rel_path(Some(config.diary_rel_path.clone())) + .with_file_extension(Some(config.file_extension.clone())); let index = Arc::new(RwLock::new(index)); Self { id, config, index } }