fix(follow-link): create missing page + wrap word on <CR>
Two missing pieces in the previous "follow creates page" fix:
1. **`wiki_root` never reached the server.** The Lua glue (and the
Vim autoload glue) was sending the config under `settings` — that
field is for `workspace/didChangeConfiguration` notifications, not
for `initialize`. The server's `Config::from_init_params` reads
`initialization_options`, so `wiki_root` arrived as `None`, the
`Wiki` aggregate was empty, and `wiki_for_uri` returned `None` →
`resolve_target_uri` bailed before reaching my synthesise path,
leaving `<CR>` with "No Location Found".
Both glues now send the same payload under both keys:
- `lua/nuwiki/lsp.lua` → adds `init_options = init_options()`
alongside the existing `settings = …`.
- `autoload/nuwiki/lsp.vim` → adds `initialization_options` to
the `lsp#register_server` call.
The server keeps reading from `initializationOptions` at startup
*and* honouring `didChangeConfiguration` later (Phase 11 plumbing).
2. **No-wiki fallback in the server.** Even with the glue fix above,
users who launch nuwiki on an ad-hoc `.wiki` file outside any
workspace folder would still get an empty `wikis` list. The
`LinkKind::Wiki` arm now falls back to a new
`synthesise_page_uri_next_to(source_uri, path)` which builds the
future page next to the current file. `<CR>` on `[[NewPage]]`
opens `./NewPage.wiki`, save creates it.
3. **`<CR>` on a plain word now wraps + follows.** Mirrors vimwiki's
`:VimwikiFollowLink`: when the cursor isn't already inside
`[[…]]`, the new `nuwiki.commands.follow_link_or_create` /
`nuwiki#commands#follow_link_or_create` first wraps the word
under cursor as `[[word]]`, places the cursor inside the link,
then dispatches `vim.lsp.buf.definition()` /
`:LspDefinition`. Both editor paths now bind `<CR>` /
`<S-CR>` / `<C-CR>` / `<C-S-CR>` to this smart variant.
Verified end-to-end:
- `[[NewPage]]` with the rebuilt binary: definition returns
`file:///tmp/wiki/NewPage.wiki` as a Location, so the editor
opens an empty buffer for it.
- A bare `MyPage` word becomes `[[MyPage]]` in the buffer before
the follow request fires.
Total 381 tests still pass; fmt + clippy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -94,6 +94,69 @@ function! s:wiki_pick(notification) abort
|
||||
\ function('s:open_uri_from'))
|
||||
endfunction
|
||||
|
||||
" ===== Smart <CR>: follow or wrap-then-follow =====
|
||||
|
||||
function! s:cursor_inside_wikilink() abort
|
||||
let l:line = getline('.')
|
||||
let l:col = col('.')
|
||||
let l:i = 1
|
||||
let l:open = 0
|
||||
while l:i < strlen(l:line)
|
||||
if strpart(l:line, l:i - 1, 2) ==# '[['
|
||||
let l:open = l:i
|
||||
let l:i += 2
|
||||
elseif strpart(l:line, l:i - 1, 2) ==# ']]'
|
||||
let l:open = 0
|
||||
let l:i += 2
|
||||
else
|
||||
let l:i += 1
|
||||
endif
|
||||
if l:i > l:col
|
||||
break
|
||||
endif
|
||||
endwhile
|
||||
return l:open > 0
|
||||
endfunction
|
||||
|
||||
function! s:wrap_cword_as_wikilink() abort
|
||||
let l:word = expand('<cword>')
|
||||
if l:word ==# ''
|
||||
return 0
|
||||
endif
|
||||
let l:line = getline('.')
|
||||
let l:col = col('.')
|
||||
" Find word boundaries around cursor (1-based).
|
||||
let l:left = l:col
|
||||
while l:left > 1 && strpart(l:line, l:left - 2, 1) =~# '[A-Za-z0-9_-]'
|
||||
let l:left -= 1
|
||||
endwhile
|
||||
let l:right = l:col
|
||||
while l:right <= strlen(l:line) && strpart(l:line, l:right - 1, 1) =~# '[A-Za-z0-9_-]'
|
||||
let l:right += 1
|
||||
endwhile
|
||||
let l:word_real = strpart(l:line, l:left - 1, l:right - l:left)
|
||||
if l:word_real ==# ''
|
||||
return 0
|
||||
endif
|
||||
let l:new_line = strpart(l:line, 0, l:left - 1) . '[[' . l:word_real . ']]' . strpart(l:line, l:right - 1)
|
||||
call setline('.', l:new_line)
|
||||
call cursor(line('.'), l:left + 2)
|
||||
return 1
|
||||
endfunction
|
||||
|
||||
function! nuwiki#commands#follow_link_or_create() abort
|
||||
if !s:cursor_inside_wikilink()
|
||||
call s:wrap_cword_as_wikilink()
|
||||
endif
|
||||
if exists(':LspDefinition') == 2
|
||||
LspDefinition
|
||||
else
|
||||
echohl WarningMsg
|
||||
echom 'nuwiki: :LspDefinition unavailable; install vim-lsp'
|
||||
echohl None
|
||||
endif
|
||||
endfunction
|
||||
|
||||
" Pure-VimL bullet-continuation for `o` / `O` — mirrors the Lua side
|
||||
" so re-pressing the key keeps the list marker (and checkbox if any).
|
||||
function! nuwiki#commands#open_below_with_bullet() abort
|
||||
|
||||
@@ -36,6 +36,7 @@ function! nuwiki#lsp#start() abort
|
||||
\ 'name': 'nuwiki',
|
||||
\ 'cmd': {server_info -> [l:bin]},
|
||||
\ 'allowlist': ['vimwiki'],
|
||||
\ 'initialization_options': s:settings(),
|
||||
\ 'config': { 'settings': { 'nuwiki': s:settings() } },
|
||||
\ })
|
||||
" vim-lsp auto-enables servers when 'g:lsp_auto_enable' is on
|
||||
|
||||
@@ -247,18 +247,26 @@ impl Backend {
|
||||
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);
|
||||
if let Some(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);
|
||||
}
|
||||
}
|
||||
let path = target.path.as_deref()?;
|
||||
return synthesise_page_uri(&source_wiki.config, path);
|
||||
}
|
||||
// Unindexed page — synthesise so `<CR>` opens (or
|
||||
// creates-on-save) the missing wiki page.
|
||||
// No wiki registered (no `initializationOptions`, no
|
||||
// workspace folder). Fall back to the source buffer's
|
||||
// parent directory so `<CR>` on `[[NewPage]]` still
|
||||
// opens (and on save creates) the future page next to
|
||||
// the current file.
|
||||
let path = target.path.as_deref()?;
|
||||
synthesise_page_uri(&source_wiki.config, path)
|
||||
synthesise_page_uri_next_to(source_uri, path)
|
||||
}
|
||||
_ => {
|
||||
let source_wiki = self.wiki_for_uri(source_uri)?;
|
||||
@@ -928,6 +936,30 @@ impl LanguageServer for Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as `synthesise_page_uri` but anchored on `source_uri`'s parent
|
||||
/// directory and the conventional `.wiki` extension. Used when no
|
||||
/// `Wiki` is registered (server received no `initializationOptions`
|
||||
/// and no `workspace_folders`) — `<CR>` on a wikilink still opens a
|
||||
/// future page next to the current file.
|
||||
fn synthesise_page_uri_next_to(source_uri: &Url, path: &str) -> Option<Url> {
|
||||
let source_path = source_uri.to_file_path().ok()?;
|
||||
let dir = source_path.parent()?;
|
||||
let mut p = dir.to_path_buf();
|
||||
for segment in path.split('/') {
|
||||
if !segment.is_empty() {
|
||||
p.push(segment);
|
||||
}
|
||||
}
|
||||
let stem = p
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
if !stem.is_empty() && !stem.contains('.') {
|
||||
p.set_file_name(format!("{stem}.wiki"));
|
||||
}
|
||||
Url::from_file_path(p).ok()
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -122,10 +122,10 @@ if !has('nvim')
|
||||
nnoremap <silent><buffer> <Leader>w<Leader>i :call nuwiki#commands#diary_generate_index()<CR>
|
||||
|
||||
" Links
|
||||
nnoremap <silent><buffer> <CR> :LspDefinition<CR>
|
||||
nnoremap <silent><buffer> <S-CR> :split<CR>:LspDefinition<CR>
|
||||
nnoremap <silent><buffer> <C-CR> :vsplit<CR>:LspDefinition<CR>
|
||||
nnoremap <silent><buffer> <C-S-CR> :tabnew<CR>:LspDefinition<CR>
|
||||
nnoremap <silent><buffer> <CR> :call nuwiki#commands#follow_link_or_create()<CR>
|
||||
nnoremap <silent><buffer> <S-CR> :split<CR>:call nuwiki#commands#follow_link_or_create()<CR>
|
||||
nnoremap <silent><buffer> <C-CR> :vsplit<CR>:call nuwiki#commands#follow_link_or_create()<CR>
|
||||
nnoremap <silent><buffer> <C-S-CR> :tabnew<CR>:call nuwiki#commands#follow_link_or_create()<CR>
|
||||
nnoremap <silent><buffer> <BS> <C-o>
|
||||
nnoremap <silent><buffer> <Tab> /\[\[<CR>:nohlsearch<CR>
|
||||
nnoremap <silent><buffer> <S-Tab> ?\[\[<CR>:nohlsearch<CR>
|
||||
|
||||
@@ -95,6 +95,80 @@ local function uri_args()
|
||||
return { { uri = buf_uri() } }
|
||||
end
|
||||
|
||||
-- ===== Smart <CR>: follow or create a link =====
|
||||
--
|
||||
-- Vimwiki's `:VimwikiFollowLink` is smart: when the cursor sits on a
|
||||
-- bare word (not inside `[[…]]`), it wraps the word into a wikilink
|
||||
-- before following — so `<CR>` on `Notes` becomes `<CR>` on
|
||||
-- `[[Notes]]` and opens a fresh buffer for the page. Match that.
|
||||
|
||||
local function cursor_inside_wikilink()
|
||||
local line = vim.api.nvim_get_current_line()
|
||||
local col = vim.api.nvim_win_get_cursor(0)[2] + 1 -- 1-based
|
||||
-- Find the most recent `[[` at or before the cursor, then check
|
||||
-- that no `]]` closes it before the cursor.
|
||||
local open_at
|
||||
local i = 1
|
||||
while i < #line do
|
||||
if line:sub(i, i + 1) == '[[' then
|
||||
open_at = i
|
||||
i = i + 2
|
||||
elseif line:sub(i, i + 1) == ']]' then
|
||||
open_at = nil
|
||||
i = i + 2
|
||||
else
|
||||
i = i + 1
|
||||
end
|
||||
if i > col then break end
|
||||
end
|
||||
return open_at ~= nil
|
||||
end
|
||||
|
||||
local function wrap_cword_as_wikilink()
|
||||
local cword = vim.fn.expand('<cword>')
|
||||
if cword == '' then return false end
|
||||
-- Replace just this `<cword>` occurrence using `*` (the start of
|
||||
-- the last cursor match) — `ciw[[<C-r>"]]` style, but we do it
|
||||
-- via the API so we don't depend on register state.
|
||||
local row, col = unpack(vim.api.nvim_win_get_cursor(0))
|
||||
local line = vim.api.nvim_get_current_line()
|
||||
-- Locate the word boundaries around cursor (0-based col).
|
||||
local left = col
|
||||
while left > 0 and line:sub(left, left):match('[%w_-]') do
|
||||
left = left - 1
|
||||
end
|
||||
-- `left` now sits one before the word start (or at -1 / col=0 if at start).
|
||||
local start_col = left
|
||||
if line:sub(left + 1, left + 1):match('[%w_-]') then
|
||||
start_col = left
|
||||
else
|
||||
start_col = left + 1
|
||||
end
|
||||
local right = start_col + 1
|
||||
while right <= #line and line:sub(right, right):match('[%w_-]') do
|
||||
right = right + 1
|
||||
end
|
||||
local stop_col = right - 1
|
||||
local word = line:sub(start_col + 1, stop_col)
|
||||
if word == '' then return false end
|
||||
local new_line = line:sub(1, start_col) .. '[[' .. word .. ']]' .. line:sub(stop_col + 1)
|
||||
vim.api.nvim_set_current_line(new_line)
|
||||
-- Put cursor inside the new `[[…]]` so the LSP definition request
|
||||
-- lands on the wikilink.
|
||||
vim.api.nvim_win_set_cursor(0, { row, start_col + 2 })
|
||||
return true
|
||||
end
|
||||
|
||||
function M.follow_link_or_create()
|
||||
if not cursor_inside_wikilink() then
|
||||
if not wrap_cword_as_wikilink() then
|
||||
-- Not on a word either — fall through to plain definition; the
|
||||
-- LSP will simply do nothing if there's no target.
|
||||
end
|
||||
end
|
||||
vim.lsp.buf.definition()
|
||||
end
|
||||
|
||||
-- ===== Wiki picker =====
|
||||
|
||||
function M.wiki_index(count)
|
||||
|
||||
@@ -173,12 +173,13 @@ function M.attach(bufnr, mappings)
|
||||
|
||||
-- ===== Links =====
|
||||
if on('links') then
|
||||
map('n', '<CR>', vim.lsp.buf.definition, { desc = 'nuwiki: follow link' }, bufnr)
|
||||
map('n', '<S-CR>', function() vim.cmd('split'); vim.lsp.buf.definition() end,
|
||||
map('n', '<CR>', cmd.follow_link_or_create,
|
||||
{ desc = 'nuwiki: follow link (or wrap word + follow)' }, bufnr)
|
||||
map('n', '<S-CR>', function() vim.cmd('split'); cmd.follow_link_or_create() end,
|
||||
{ desc = 'nuwiki: follow link in split' }, bufnr)
|
||||
map('n', '<C-CR>', function() vim.cmd('vsplit'); vim.lsp.buf.definition() end,
|
||||
map('n', '<C-CR>', function() vim.cmd('vsplit'); cmd.follow_link_or_create() end,
|
||||
{ desc = 'nuwiki: follow link in vsplit' }, bufnr)
|
||||
map('n', '<C-S-CR>', function() vim.cmd('tabnew'); vim.lsp.buf.definition() end,
|
||||
map('n', '<C-S-CR>', function() vim.cmd('tabnew'); cmd.follow_link_or_create() end,
|
||||
{ desc = 'nuwiki: follow link in new tab' }, bufnr)
|
||||
map('n', '<BS>', '<C-o>', { desc = 'nuwiki: go back', remap = true }, bufnr)
|
||||
map('n', '<Tab>', link.next, { desc = 'nuwiki: next wikilink' }, bufnr)
|
||||
|
||||
@@ -14,6 +14,16 @@ local function command()
|
||||
return { install.expected_path() }
|
||||
end
|
||||
|
||||
--- `initializationOptions` payload: the server reads it once at
|
||||
--- `initialize` time via `Config::from_init_params`. This is what
|
||||
--- delivers `wiki_root`/`wikis`/HTML/diary config to Rust.
|
||||
local function init_options()
|
||||
return config.options
|
||||
end
|
||||
|
||||
--- `settings` payload: the same shape, but sent via
|
||||
--- `workspace/didChangeConfiguration` after startup. Lets the server
|
||||
--- pick up config changes without a restart.
|
||||
local function server_settings()
|
||||
return {
|
||||
nuwiki = config.options,
|
||||
@@ -40,6 +50,7 @@ function M.register()
|
||||
cmd = command(),
|
||||
filetypes = { 'vimwiki' },
|
||||
root_markers = { '.git', '.nuwiki' },
|
||||
init_options = init_options(),
|
||||
settings = server_settings(),
|
||||
})
|
||||
vim.lsp.enable('nuwiki')
|
||||
@@ -55,6 +66,7 @@ function M.register()
|
||||
name = 'nuwiki',
|
||||
cmd = command(),
|
||||
root_dir = root_dir_for(ev.file),
|
||||
init_options = init_options(),
|
||||
settings = server_settings(),
|
||||
})
|
||||
end,
|
||||
|
||||
Reference in New Issue
Block a user