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 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 22:00:03 -03:00
parent 6ae642dbee
commit 2aec51274e
4 changed files with 69 additions and 16 deletions
+4 -2
View File
@@ -166,7 +166,8 @@ pub fn classify_link(
match link.target.kind { match link.target.kind {
LinkKind::Wiki => { LinkKind::Wiki => {
let path = link.target.path.as_deref()?; 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 { match page {
None => Some(BrokenLinkKind::MissingPage(path.to_string())), None => Some(BrokenLinkKind::MissingPage(path.to_string())),
Some(page) => { Some(page) => {
@@ -227,7 +228,8 @@ pub fn classify_outgoing(
match link.kind { match link.kind {
LinkKind::Wiki => { LinkKind::Wiki => {
let path = link.target_page.as_deref()?; 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())), None => Some(BrokenLinkKind::MissingPage(path.to_string())),
Some(page) => { Some(page) => {
if let Some(anchor) = &link.anchor { if let Some(anchor) = &link.anchor {
+48 -5
View File
@@ -96,6 +96,10 @@ pub struct WorkspaceIndex {
/// Mirrors `WikiConfig::diary_rel_path`; stored here so `upsert` can /// Mirrors `WikiConfig::diary_rel_path`; stored here so `upsert` can
/// classify each indexed URI without a back-reference to the config. /// classify each indexed URI without a back-reference to the config.
pub diary_rel_path: Option<String>, pub diary_rel_path: Option<String>,
/// 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<String>,
pub pages_by_uri: HashMap<Url, IndexedPage>, pub pages_by_uri: HashMap<Url, IndexedPage>,
pub pages_by_name: HashMap<String, Url>, pub pages_by_name: HashMap<String, Url>,
pub backlinks: HashMap<String, Vec<Backlink>>, pub backlinks: HashMap<String, Vec<Backlink>>,
@@ -118,6 +122,19 @@ impl WorkspaceIndex {
self self
} }
pub fn with_file_extension(mut self, ext: Option<String>) -> 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 /// Insert or update an indexed page. Removes any prior indexing for
/// the same URI first (handles renames and re-parses without leaking /// the same URI first (handles renames and re-parses without leaking
/// stale backlinks). /// stale backlinks).
@@ -142,10 +159,12 @@ impl WorkspaceIndex {
}; };
index_blocks(&ast.children, &mut page); index_blocks(&ast.children, &mut page);
let ext = self.file_extension.as_deref();
for link in &page.outgoing { for link in &page.outgoing {
if let Some(tgt) = &link.target_page { if let Some(tgt) = &link.target_page {
let norm = strip_wiki_extension(tgt, ext);
self.backlinks self.backlinks
.entry(tgt.clone()) .entry(norm.to_string())
.or_default() .or_default()
.push(Backlink { .push(Backlink {
source_uri: uri.clone(), source_uri: uri.clone(),
@@ -199,10 +218,10 @@ impl WorkspaceIndex {
/// needed for `AnchorOnly` resolution. /// needed for `AnchorOnly` resolution.
pub fn resolve(&self, target: &LinkTarget, source_page: &str) -> Option<&Url> { pub fn resolve(&self, target: &LinkTarget, source_page: &str) -> Option<&Url> {
match target.kind { match target.kind {
LinkKind::Wiki => target LinkKind::Wiki => target.path.as_deref().and_then(|p| {
.path let normalized = self.strip_link_extension(p);
.as_deref() self.pages_by_name.get(normalized)
.and_then(|p| self.pages_by_name.get(p)), }),
LinkKind::AnchorOnly => self.pages_by_name.get(source_page), LinkKind::AnchorOnly => self.pages_by_name.get(source_page),
// Interwiki / Diary / File / Local / Raw aren't resolved // Interwiki / Diary / File / Local / Raw aren't resolved
// against the workspace index here. The handlers fall back to // 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 ===== // ===== 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 /// Recursively collect every `.wiki` file under `root`, skipping dotfiles
/// and dot-directories. /// and dot-directories.
pub fn walk_wiki_files(root: &Path) -> Vec<PathBuf> { pub fn walk_wiki_files(root: &Path) -> Vec<PathBuf> {
+9 -2
View File
@@ -240,7 +240,8 @@ impl Backend {
}; };
let path = target.path.as_deref()?; let path = target.path.as_deref()?;
if let Ok(idx) = target_wiki.index.read() { 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); 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())); 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) { let target_page = match nav::find_inline_at(&doc.ast, line, col) {
Some(InlineNode::WikiLink(w)) => match w.target.kind { 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()), nuwiki_core::ast::LinkKind::AnchorOnly => Some(source_page.clone()),
_ => None, _ => None,
}, },
@@ -977,6 +981,8 @@ fn synthesise_page_uri(cfg: &crate::config::WikiConfig, path: &str) -> Option<Ur
} }
let ext = cfg.file_extension.trim_start_matches('.'); let ext = cfg.file_extension.trim_start_matches('.');
if !ext.is_empty() { if !ext.is_empty() {
let current_ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
if current_ext != ext {
let stem = p let stem = p
.file_name() .file_name()
.map(|s| s.to_string_lossy().into_owned()) .map(|s| s.to_string_lossy().into_owned())
@@ -985,6 +991,7 @@ fn synthesise_page_uri(cfg: &crate::config::WikiConfig, path: &str) -> Option<Ur
p.set_file_name(format!("{stem}.{ext}")); p.set_file_name(format!("{stem}.{ext}"));
} }
} }
}
Url::from_file_path(p).ok() Url::from_file_path(p).ok()
} }
+2 -1
View File
@@ -26,7 +26,8 @@ pub struct Wiki {
impl Wiki { impl Wiki {
pub fn new(id: WikiId, config: WikiConfig) -> Self { pub fn new(id: WikiId, config: WikiConfig) -> Self {
let index = WorkspaceIndex::new(Some(config.root.clone())) 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)); let index = Arc::new(RwLock::new(index));
Self { id, config, index } Self { id, config, index }
} }