fix(vim): follow-link creates missing pages + safe LSP foldexpr
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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Regression: `<CR>` on a wikilink to a page that doesn't exist yet
|
||||
//! must still resolve to a URI so the editor opens (and on save
|
||||
//! creates) the future page. The fix lives in `Backend::resolve_target_uri`
|
||||
//! but the resolver is private; we exercise the synthesised-path
|
||||
//! semantics via the public config helpers + index lookup.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::LinkKind;
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::config::WikiConfig;
|
||||
use nuwiki_lsp::index::WorkspaceIndex;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_to_indexed_page_resolves_via_index() {
|
||||
let root = "/tmp/follow-link-1";
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
let target = Url::from_file_path(format!("{root}/Notes.wiki")).unwrap();
|
||||
idx.upsert(target.clone(), &parse("= notes =\n"));
|
||||
|
||||
let resolved = idx.pages_by_name.get("Notes").cloned();
|
||||
assert_eq!(resolved, Some(target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_page_is_not_in_index() {
|
||||
let root = "/tmp/follow-link-2";
|
||||
let idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
// Empty index — "Missing" isn't there, so the fallback synthesise
|
||||
// path is the only way to follow `[[Missing]]`.
|
||||
assert!(!idx.pages_by_name.contains_key("Missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_root_plus_path_plus_ext_is_a_valid_uri() {
|
||||
// Sanity: the synthesise logic constructs <root>/<path>.<ext>.
|
||||
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/follow-link-3"));
|
||||
assert_eq!(cfg.file_extension, ".wiki");
|
||||
let target_path = cfg.root.join("Missing.wiki");
|
||||
assert!(Url::from_file_path(&target_path).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_to_subdir_page_parses_with_slash_in_path() {
|
||||
// Synthesis must walk path segments, not just concatenate.
|
||||
let doc = parse("[[notes/Daily]]\n");
|
||||
let inline = match &doc.children[0] {
|
||||
nuwiki_core::ast::BlockNode::Paragraph(p) => &p.children[0],
|
||||
_ => panic!("expected paragraph"),
|
||||
};
|
||||
let target = match inline {
|
||||
nuwiki_core::ast::InlineNode::WikiLink(w) => &w.target,
|
||||
_ => panic!("expected wikilink"),
|
||||
};
|
||||
assert_eq!(target.kind, LinkKind::Wiki);
|
||||
assert_eq!(target.path.as_deref(), Some("notes/Daily"));
|
||||
}
|
||||
@@ -39,4 +39,20 @@ function M.foldtext()
|
||||
return string.format('▸ %s … (%d lines)', line, count)
|
||||
end
|
||||
|
||||
--- LSP-backed foldexpr with a safe fallback.
|
||||
---
|
||||
--- Wraps `vim.lsp.foldexpr` so a missing client / un-attached buffer /
|
||||
--- transient evaluation error falls back to the regex implementation
|
||||
--- instead of throwing E5108 for every line in the buffer.
|
||||
function M.lsp_expr()
|
||||
if not (vim.lsp and vim.lsp.foldexpr) then
|
||||
return M.expr()
|
||||
end
|
||||
local ok, val = pcall(vim.lsp.foldexpr)
|
||||
if ok then
|
||||
return val
|
||||
end
|
||||
return M.expr()
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -13,6 +13,10 @@ local function setup_folding(bufnr, folding_mode)
|
||||
-- The ftplugin runs during BufRead → FileType, by which time the
|
||||
-- buffer is displayed in the current window. `vim.opt_local` writes
|
||||
-- to the current window's view of the option.
|
||||
-- The LSP foldexpr is wrapped via `nuwiki.folding.lsp_expr` so a
|
||||
-- transient client failure (e.g. server still starting, buffer not
|
||||
-- yet attached) degrades to the regex variant instead of spamming
|
||||
-- the user with E5108 errors per line.
|
||||
local apply = function()
|
||||
if folding_mode == 'expr'
|
||||
or not (vim.fn.has('nvim-0.11') == 1 and vim.lsp.foldexpr)
|
||||
@@ -21,9 +25,9 @@ local function setup_folding(bufnr, folding_mode)
|
||||
vim.opt_local.foldexpr = 'v:lua.require("nuwiki.folding").expr()'
|
||||
vim.opt_local.foldtext = 'v:lua.require("nuwiki.folding").foldtext()'
|
||||
else
|
||||
-- LSP-backed folding via Neovim 0.11+'s built-in foldexpr.
|
||||
vim.opt_local.foldmethod = 'expr'
|
||||
vim.opt_local.foldexpr = 'v:lua.vim.lsp.foldexpr()'
|
||||
vim.opt_local.foldexpr = 'v:lua.require("nuwiki.folding").lsp_expr()'
|
||||
vim.opt_local.foldtext = 'v:lua.require("nuwiki.folding").foldtext()'
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user