fix(vim): follow-link creates missing pages + safe LSP foldexpr
CI / cargo fmt --check (push) Successful in 17s
CI / cargo clippy (push) Successful in 1m7s
CI / cargo test (push) Successful in 1m13s

Two user-visible bugs:

1. `<CR>` on a wikilink whose target wasn't indexed yet (typical for
   "follow this link → page doesn't exist → I want a fresh buffer for
   it") did nothing. Cause: `Backend::resolve_target_uri` returned
   `None` once `WorkspaceIndex::resolve` came up empty, so
   `goto_definition` had no Location to hand back to the client and
   `vim.lsp.buf.definition()` / `:LspDefinition` silently no-op'd.

   Vimwiki's behaviour is "open a fresh buffer for the future page".
   `Backend::resolve_target_uri` now synthesises a
   `<wiki_root>/<path><file_extension>` URI when the lookup misses,
   for both `LinkKind::Wiki` and the `LinkKind::Interwiki` fallback.
   The editor opens an empty buffer; saving writes the file. New
   helper `synthesise_page_uri` does the path walk so subdirectory
   wikilinks like `[[notes/Daily]]` get the right output path.

2. Folding occasionally surfaced an `E5108` from inside
   `vim.lsp.foldexpr()` — happens when the function fires before the
   LSP client has finished attaching to the buffer, or when the
   server's `foldingRange` response hasn't yet arrived. The error
   gets shouted once per visible line.

   `lua/nuwiki/folding.lua` now exports `lsp_expr` which `pcall`s
   `vim.lsp.foldexpr` and falls back to the regex implementation
   (`M.expr`) on error or when the LSP function isn't available.
   `ftplugin.lua` points `foldexpr` at the wrapper instead of
   `vim.lsp.foldexpr` directly. `foldtext` is set in both branches
   now so collapsed folds keep the nicer summary line.

Tests: 4 new in `phase19_followlink_creates.rs` covering indexed
resolution, missing-page fallback, the canonical
`<root>/<page>.wiki` shape, and the slash-bearing subdirectory link.
Total 381 tests pass; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 01:22:35 +00:00
parent c3a8eae9d0
commit 047c9a3cf3
4 changed files with 145 additions and 10 deletions
+60 -8
View File
@@ -210,13 +210,19 @@ impl Backend {
/// Resolve a `LinkTarget` to the destination page's URI.
///
/// - `LinkKind::Wiki` / `LinkKind::AnchorOnly` — uses the source
/// document's wiki index (same as Phase 8 behaviour).
/// - `LinkKind::Wiki` — looks the page up in the source wiki's
/// index first; if it isn't indexed yet (the user is following
/// a link to a page that doesn't exist on disk), synthesises the
/// URI from the wiki root + path + file extension so the editor
/// can open a fresh buffer for it. Matches vimwiki's
/// "follow-link creates the page on save" behaviour.
/// - `LinkKind::AnchorOnly` — same source-wiki lookup; returns
/// `None` when the source page isn't indexed.
/// - `LinkKind::Interwiki` — routes to the wiki referenced by
/// `wiki_index` or `wiki_name`, then looks up the page in that
/// wiki's index. This is the Phase 18 cross-wiki bridge.
///
/// Returns `None` when no wiki matches or the page isn't indexed.
/// `wiki_index` or `wiki_name`. Falls back to a synthesised URI
/// in the target wiki's root when the page isn't indexed.
/// - Other kinds (`File`/`Local`/`Diary`/`Raw`) — no resolution
/// here; the LSP handler builds those URIs separately.
fn resolve_target_uri(
&self,
target: &nuwiki_core::ast::LinkTarget,
@@ -233,8 +239,26 @@ impl Backend {
return None;
};
let path = target.path.as_deref()?;
let idx = target_wiki.index.read().ok()?;
idx.pages_by_name.get(path).cloned()
if let Ok(idx) = target_wiki.index.read() {
if let Some(uri) = idx.pages_by_name.get(path).cloned() {
return Some(uri);
}
}
synthesise_page_uri(&target_wiki.config, path)
}
LinkKind::Wiki => {
let source_wiki = self.wiki_for_uri(source_uri)?;
let source_page =
index::page_name_from_uri(source_uri, Some(source_wiki.config.root.as_path()));
if let Ok(idx) = source_wiki.index.read() {
if let Some(uri) = idx.resolve(target, &source_page).cloned() {
return Some(uri);
}
}
// Unindexed page — synthesise so `<CR>` opens (or
// creates-on-save) the missing wiki page.
let path = target.path.as_deref()?;
synthesise_page_uri(&source_wiki.config, path)
}
_ => {
let source_wiki = self.wiki_for_uri(source_uri)?;
@@ -904,6 +928,34 @@ impl LanguageServer for Backend {
}
}
/// Build a `<wiki_root>/<page><file_extension>` URI for a wikilink that
/// doesn't (yet) resolve to an indexed page. Used by
/// `Backend::resolve_target_uri` so `<CR>` on `[[Newish]]` opens the
/// future page in the editor — saving creates it. Slashes inside `path`
/// are honoured for subdirectory pages.
fn synthesise_page_uri(cfg: &crate::config::WikiConfig, path: &str) -> Option<Url> {
if cfg.root.as_os_str().is_empty() {
return None;
}
let mut p = cfg.root.clone();
for segment in path.split('/') {
if !segment.is_empty() {
p.push(segment);
}
}
let ext = cfg.file_extension.trim_start_matches('.');
if !ext.is_empty() {
let stem = p
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
if !stem.is_empty() {
p.set_file_name(format!("{stem}.{ext}"));
}
}
Url::from_file_path(p).ok()
}
/// File-operation capability for `.wiki` files. Tells the client to
/// notify us before/after the user renames or deletes files via the file
/// explorer (or any other out-of-buffer trigger). Filters scope these