Files
nuwiki/lua/nuwiki/keymaps.lua
T
gffranco 4bee216c05
CI / editor tests (push) Successful in 45s
refactor(client): self-review cleanup — dead code, dedup, parity, stale comments
A code-review pass over the VimL + Lua client layers (no behaviour change
beyond the noted parity fixes; full editor harness 10/10):

Lua (lua/nuwiki/):
- commands.lua: drop the dead <0.10 make_position_params pcall/no-arg fallback
  (min is 0.11; no-arg form is itself deprecated) and its stale comment.
- commands.lua: simplify the exec() client dispatch — the metatable/rawget
  dance is gone; always use the non-deprecated colon form client:request() on
  0.11+. (This is the block an external review misread as "critical".)
- commands.lua: extract parse_list_marker() + has_auto_indent() shared by
  smart_return/smart_shift_return; extract word_boundaries() shared by
  wrap_cword_as_wikilink/colorize (and unify the equivalent char classes).
- commands.lua: :Nuwiki search now surfaces real lvimgrep errors instead of
  reporting every failure as "no match" (E480 stays a WARN) — parity with the
  VimL twin's `catch /E480/`.
- keymaps.lua: collapse open_below/above_with_bullet into open_with_bullet(cmd)
  (checkbox prefix preserved for `o` only, as before).
- lsp.lua: normalize the wiki root before the buffer-path prefix compare
  (Windows separators); buf path was already normalized.
- ftplugin.lua: drop the always-true has('nvim-0.11') guard and hoist the
  identical foldmethod/foldtext out of both branches.
- install.lua: use a local `uv` instead of mutating the global vim.uv.

VimL (autoload/nuwiki/):
- commands.vim: refresh a stale comment pointing at the old in-repo
  nuwiki-lsp/src/commands.rs path → the nuwiki-rs server generally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:59:52 +00:00

332 lines
16 KiB
Lua

-- lua/nuwiki/keymaps.lua — buffer-local key bindings for `.wiki` buffers.
--
-- Defaults mirror upstream vimwiki's ftplugin layout (see
-- github.com/vimwiki/vimwiki ftplugin/vimwiki.vim), grouped so users
-- can flip subgroups off independently via the `mappings` config:
--
-- mappings = {
-- enabled = true,
-- links = true, -- <CR>, <S-CR>, <Tab>, <BS>, +, …
-- lists = true, -- <C-Space>, gln/glp, glx, gl/gL, …
-- headers = true, -- =, -, ]], [[, ]=, [=, ]u, [u
-- table_editing = true, -- gqq, gq1, gww, gw1, <A-Left>, <A-Right>
-- html_export = true, -- <Leader>wh, <Leader>whh
-- diary = true, -- <C-Down>, <C-Up>, <Leader>w<Leader>*
-- text_objects = true, -- ah, ih, al, il
-- wiki_prefix = true, -- <Leader>ww, <Leader>ws, <Leader>wi, …
-- }
local M = {}
local function map(mode, lhs, rhs, opts, bufnr)
opts = vim.tbl_extend('force', { buffer = bufnr or 0, silent = true }, opts or {})
vim.keymap.set(mode, lhs, rhs, opts)
end
-- ===== Header navigation (pure Lua) =====
--
-- Pure regex jumps, no LSP round-trip. The patterns mirror what
-- vimwiki's `vimwiki#base#goto_*_header` family does, just simpler:
-- match `^\s*=` runs as heading lines.
local heading_level = require('nuwiki.util').heading_level
local function jump_heading(direction, predicate)
local total = vim.api.nvim_buf_line_count(0)
local lnum = vim.fn.line('.')
local step = direction > 0 and 1 or -1
local i = lnum + step
while i >= 1 and i <= total do
local lvl = heading_level(vim.fn.getline(i))
if lvl > 0 and predicate(lvl, i) then
vim.api.nvim_win_set_cursor(0, { i, 0 })
return
end
i = i + step
end
end
local function current_heading_level()
local total = vim.api.nvim_buf_line_count(0)
local lnum = vim.fn.line('.')
for i = lnum, 1, -1 do
local lvl = heading_level(vim.fn.getline(i))
if lvl > 0 then return lvl, i end
end
return 0, nil
end
local nav = {}
function nav.next_header() jump_heading(1, function() return true end) end
function nav.prev_header() jump_heading(-1, function() return true end) end
function nav.next_sibling_header()
local lvl = current_heading_level()
jump_heading(1, function(l) return l <= lvl end)
end
function nav.prev_sibling_header()
local lvl = current_heading_level()
jump_heading(-1, function(l) return l <= lvl end)
end
function nav.parent_header()
local lvl = current_heading_level()
if lvl <= 1 then return end
jump_heading(-1, function(l) return l < lvl end)
end
-- ===== Link navigation (pure Lua) =====
--
-- `<Tab>` / `<S-Tab>` jump to the next/prev `[[…]]` on or after the
-- cursor line. The server-side `goto_definition` handles the actual
-- follow; this is just movement.
local function jump_to_pattern(pattern, direction)
-- Vim's `searchpos` handles forward/backward + wrap.
local flags = direction > 0 and 'W' or 'bW'
local pos = vim.fn.searchpos(pattern, flags)
if pos[1] > 0 then
vim.api.nvim_win_set_cursor(0, { pos[1], pos[2] - 1 })
end
end
local link = {}
function link.next() jump_to_pattern([[\[\[]], 1) end
function link.prev() jump_to_pattern([[\[\[]], -1) end
-- ===== List "o"/"O" continuation =====
--
-- Vimwiki's o/O preserves the list bullet on the new line. We
-- approximate: when the current line starts with a list marker, insert
-- the marker on the new line; otherwise fall back to plain o/O.
-- `cmd` is 'o' (open below) or 'O' (open above). Off a list item it's a plain
-- o/O. The below ('o') variant also re-adds a `[ ] ` checkbox prefix when the
-- source item has one, so the continuation is a fresh unchecked item; the
-- above ('O') variant inserts a bare bullet (preserves prior behaviour).
local function open_with_bullet(cmd)
local line = vim.fn.getline('.')
local indent, marker = line:match('^(%s*)([%-%*#])%s')
if not marker then
vim.api.nvim_feedkeys(cmd, 'n', false)
return
end
local prefix = indent .. marker .. ' '
if cmd == 'o' and line:match('^%s*[%-%*#]%s+(%[[%sxXoO%.%-]%])') then
prefix = prefix .. '[ ] '
end
vim.api.nvim_feedkeys(cmd .. prefix, 'n', false)
end
-- ===== Public attach =====
function M.attach(bufnr, mappings)
local cmd = require('nuwiki.commands')
if not mappings or mappings.enabled == false then
return
end
-- Default a missing subgroup to true so users only opt *out*.
local function on(group)
if mappings[group] == nil then return true end
return mappings[group] ~= false
end
-- Configurable wiki command prefix (vimwiki's `g:vimwiki_map_prefix`).
-- Every `<prefix>…` mapping below is built from this so users can relocate
-- the whole family by setting `map_prefix` in setup().
local p = require('nuwiki.config').options.map_prefix or '<Leader>w'
-- ===== Wiki prefix (`<prefix>*`, default `<Leader>w*`) =====
if on('wiki_prefix') then
map('n', p .. 'w', function() cmd.wiki_index(0) end, { desc = 'nuwiki: open wiki index' }, bufnr)
map('n', p .. 't', function() cmd.wiki_tab_index(0) end, { desc = 'nuwiki: open wiki index (tab)' }, bufnr)
map('n', p .. 's', cmd.wiki_ui_select, { desc = 'nuwiki: pick wiki' }, bufnr)
map('n', p .. 'i', cmd.diary_index, { desc = 'nuwiki: open diary index' }, bufnr)
map('n', p .. '<Leader>w', cmd.diary_today, { desc = 'nuwiki: today' }, bufnr)
map('n', p .. '<Leader>y', cmd.diary_yesterday, { desc = 'nuwiki: yesterday' }, bufnr)
map('n', p .. '<Leader>t', cmd.diary_today_tab, { desc = 'nuwiki: today in a new tab' }, bufnr)
map('n', p .. '<Leader>m', cmd.diary_tomorrow, { desc = 'nuwiki: tomorrow (vimwiki <prefix><Leader>m)' }, bufnr)
map('n', p .. '<Leader>i', cmd.diary_generate_index, { desc = 'nuwiki: rebuild diary index' }, bufnr)
end
-- ===== Links =====
if on('links') then
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'); cmd.follow_link_or_create() end,
{ desc = 'nuwiki: follow link in vsplit' }, bufnr)
map('n', '<C-S-CR>', cmd.follow_link_drop,
{ desc = 'nuwiki: follow link in tab (reuse existing)' }, bufnr)
map('n', '<D-CR>', cmd.follow_link_drop,
{ desc = 'nuwiki: follow link in tab (macOS Cmd alias of <C-S-CR>)' }, bufnr)
map('n', '<M-CR>', cmd.badd_link,
{ desc = 'nuwiki: add link target to buffer list' }, bufnr)
map('n', '<BS>', '<C-o>', { desc = 'nuwiki: go back', remap = true }, bufnr)
map('n', '<Tab>', link.next, { desc = 'nuwiki: next wikilink' }, bufnr)
map('n', '<S-Tab>', link.prev, { desc = 'nuwiki: prev wikilink' }, bufnr)
map('n', '+', cmd.normalize_link, { desc = 'nuwiki: wrap word as wikilink' }, bufnr)
map('x', '+', function() cmd.normalize_link(true) end,
{ desc = 'nuwiki: wrap selection as wikilink' }, bufnr)
-- Upstream binds visual <CR> to the same normalize-link as visual `+`.
map('x', '<CR>', function() cmd.normalize_link(true) end,
{ desc = 'nuwiki: wrap selection as wikilink' }, bufnr)
map('n', p .. 'n', cmd.wiki_goto_page, { desc = 'nuwiki: goto page' }, bufnr)
map('n', p .. 'd', cmd.delete_file, { desc = 'nuwiki: delete page' }, bufnr)
map('n', p .. 'r', cmd.rename_file, { desc = 'nuwiki: rename page' }, bufnr)
map('n', p .. 'c', function() cmd.colorize() end,
{ desc = 'nuwiki: wrap word in colour span' }, bufnr)
map('x', p .. 'c', function() cmd.colorize(nil, true) end,
{ desc = 'nuwiki: wrap selection in colour span' }, bufnr)
end
-- ===== Diary nav =====
if on('diary') then
map('n', '<C-Down>', cmd.diary_next, { desc = 'nuwiki: next diary entry' }, bufnr)
map('n', '<C-Up>', cmd.diary_prev, { desc = 'nuwiki: prev diary entry' }, bufnr)
end
-- ===== Lists =====
if on('lists') then
-- Three aliases for the same intent. Terminals disagree about
-- what byte(s) Ctrl+Space produces:
-- * GUI Neovim / kitty-protocol terminals → `<C-Space>`
-- * xterm, most legacy terminals → NUL byte → `<C-@>`
-- * Some Neovim builds spell that same byte `<Nul>` in keymaps
-- Map all three so the user gets the binding regardless.
map('n', '<C-Space>', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr)
map('x', '<C-Space>', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr)
map('n', '<C-@>', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr)
map('x', '<C-@>', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr)
map('n', '<Nul>', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr)
map('x', '<Nul>', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr)
map('n', 'gnt', cmd.next_task, { desc = 'nuwiki: next unfinished task' }, bufnr)
map('n', 'gln', cmd.cycle_list_item, { desc = 'nuwiki: increment checkbox' }, bufnr)
map('x', 'gln', cmd.cycle_list_item, { desc = 'nuwiki: increment checkbox' }, bufnr)
map('n', 'glp', cmd.cycle_list_item_back, { desc = 'nuwiki: decrement checkbox' }, bufnr)
map('x', 'glp', cmd.cycle_list_item_back, { desc = 'nuwiki: decrement checkbox' }, bufnr)
map('n', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr)
map('x', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr)
map('n', 'glh', function() cmd.list_change_level(-1, false) end,
{ desc = 'nuwiki: list dedent' }, bufnr)
map('n', 'gll', function() cmd.list_change_level(1, false) end,
{ desc = 'nuwiki: list indent' }, bufnr)
map('n', 'gLh', function() cmd.list_change_level(-1, true) end,
{ desc = 'nuwiki: list dedent subtree' }, bufnr)
map('n', 'gLl', function() cmd.list_change_level(1, true) end,
{ desc = 'nuwiki: list indent subtree' }, bufnr)
-- Upstream binds case-variant aliases gLH/gLL/gLR == gLh/gLl/gLr.
map('n', 'gLH', function() cmd.list_change_level(-1, true) end,
{ desc = 'nuwiki: list dedent subtree' }, bufnr)
map('n', 'gLL', function() cmd.list_change_level(1, true) end,
{ desc = 'nuwiki: list indent subtree' }, bufnr)
map('n', 'glr', cmd.list_renumber,
{ desc = 'nuwiki: renumber list' }, bufnr)
map('n', 'gLr', cmd.list_renumber_all,
{ desc = 'nuwiki: renumber all lists' }, bufnr)
map('n', 'gLR', cmd.list_renumber_all,
{ desc = 'nuwiki: renumber all lists' }, bufnr)
-- gl<sym>/gL<sym>: set the current item's / whole list's marker (upstream
-- VimwikiChangeSymbolTo / ChangeSymbolInListTo). 1) is command-only.
for _, pair in ipairs({
{ '-', '-' }, { '*', '*' }, { '#', '#' }, { '1', '1.' },
{ 'i', 'i)' }, { 'I', 'I)' }, { 'a', 'a)' }, { 'A', 'A)' },
}) do
local key, sym = pair[1], pair[2]
map('n', 'gl' .. key, function() cmd.list_change_symbol(sym, false) end,
{ desc = 'nuwiki: set item marker to ' .. sym }, bufnr)
map('n', 'gL' .. key, function() cmd.list_change_symbol(sym, true) end,
{ desc = 'nuwiki: set list markers to ' .. sym }, bufnr)
end
-- Bare gl/gL: remove the checkbox marker, keep the item text (upstream
-- VimwikiRemoveSingleCB / RemoveCBInList). They share the `gl…` prefix
-- with the maps above, so they fire after `timeoutlen` — exactly as
-- upstream. Remove-done is command-only (`:NuwikiRemoveDone[!]`).
map('n', 'gl', cmd.list_remove_checkbox,
{ desc = 'nuwiki: remove checkbox (current item)' }, bufnr)
map('n', 'gL', cmd.list_remove_checkbox_in_list,
{ desc = 'nuwiki: remove checkboxes (whole list)' }, bufnr)
map('n', 'o', function() open_with_bullet('o') end, { desc = 'nuwiki: open below + bullet' }, bufnr)
map('n', 'O', function() open_with_bullet('O') end, { desc = 'nuwiki: open above + bullet' }, bufnr)
-- Insert-mode bindings (matches upstream vimwiki). The list helpers
-- mutate the buffer via the LSP / `nvim_buf_set_lines`, both of
-- which work cleanly from insert mode, so no `<C-o>` dance needed.
map('i', '<C-D>', function() cmd.list_change_level(-1, false) end,
{ desc = 'nuwiki: list dedent (insert)' }, bufnr)
map('i', '<C-T>', function() cmd.list_change_level(1, false) end,
{ desc = 'nuwiki: list indent (insert)' }, bufnr)
map('i', '<C-L><C-J>', function() cmd.list_cycle_symbol(1) end,
{ desc = 'nuwiki: cycle list symbol (insert)' }, bufnr)
map('i', '<C-L><C-K>', function() cmd.list_cycle_symbol(-1) end,
{ desc = 'nuwiki: cycle list symbol backward (insert)' }, bufnr)
map('i', '<C-L><C-M>', cmd.list_toggle_or_add_checkbox,
{ desc = 'nuwiki: toggle/add checkbox (insert)' }, bufnr)
-- Smart <CR>: continue list / table rows. `<expr>` so the function
-- result is the keystrokes fed — keeps undo coherent.
map('i', '<CR>', cmd.smart_return,
{ desc = 'nuwiki: smart return (insert)', expr = true }, bufnr)
-- Smart <S-CR>: multiline list item (continuation with no new marker).
map('i', '<S-CR>', cmd.smart_shift_return,
{ desc = 'nuwiki: multiline list item (insert)', expr = true }, bufnr)
end
-- ===== Insert-mode table navigation (Cluster 6) =====
-- Gated under `table_editing` since it's the same surface.
if on('table_editing') then
map('i', '<Tab>', cmd.smart_tab,
{ desc = 'nuwiki: next table cell (insert)', expr = true }, bufnr)
map('i', '<S-Tab>', cmd.smart_shift_tab,
{ desc = 'nuwiki: prev table cell (insert)', expr = true }, bufnr)
end
-- ===== Header levels + nav =====
if on('headers') then
-- vimwiki defaults — buffer-local so they don't clash globally.
map('n', '=', cmd.heading_add_level, { desc = 'nuwiki: heading deeper' }, bufnr)
map('n', '-', cmd.heading_remove_level, { desc = 'nuwiki: heading shallower' }, bufnr)
map('n', ']]', nav.next_header, { desc = 'nuwiki: next header' }, bufnr)
map('n', '[[', nav.prev_header, { desc = 'nuwiki: prev header' }, bufnr)
map('n', ']=', nav.next_sibling_header, { desc = 'nuwiki: next sibling header' }, bufnr)
map('n', '[=', nav.prev_sibling_header, { desc = 'nuwiki: prev sibling header' }, bufnr)
map('n', ']u', nav.parent_header, { desc = 'nuwiki: parent header' }, bufnr)
map('n', '[u', nav.parent_header, { desc = 'nuwiki: parent header' }, bufnr)
end
-- ===== Tables =====
if on('table_editing') then
map('n', 'gqq', function() cmd.table_align_or_cmd('gqq') end, { desc = 'nuwiki: align table / gqq' }, bufnr)
map('n', 'gq1', function() cmd.table_align_or_cmd('gqq') end, { desc = 'nuwiki: align table / gqq' }, bufnr)
map('n', 'gww', function() cmd.table_align_or_cmd('gww') end, { desc = 'nuwiki: align table / gww' }, bufnr)
map('n', 'gw1', function() cmd.table_align_or_cmd('gww') end, { desc = 'nuwiki: align table / gww' }, bufnr)
map('n', '<A-Left>', cmd.table_move_column_left, { desc = 'nuwiki: move column left' }, bufnr)
map('n', '<A-Right>', cmd.table_move_column_right, { desc = 'nuwiki: move column right' }, bufnr)
end
-- ===== HTML =====
if on('html_export') then
map('n', p .. 'h', cmd.export_current, { desc = 'nuwiki: export to HTML' }, bufnr)
map('n', p .. 'hh', cmd.export_browse, { desc = 'nuwiki: export + browse' }, bufnr)
map('n', p .. 'ha', cmd.export_all, { desc = 'nuwiki: export all' }, bufnr)
end
-- ===== Mouse (opt-in via `mappings.mouse = true`) =====
-- Mirrors upstream vimwiki: double-click follows, shift/ctrl-double
-- open in split/vsplit, middle-click adds the linked file to the
-- buffer list, right-click + left-click goes back.
if mappings.mouse then
map('n', '<2-LeftMouse>', cmd.follow_link_or_create,
{ desc = 'nuwiki: follow link (mouse)' }, bufnr)
map('n', '<S-2-LeftMouse>', function() vim.cmd('split'); cmd.follow_link_or_create() end,
{ desc = 'nuwiki: follow link in split (mouse)' }, bufnr)
map('n', '<C-2-LeftMouse>', function() vim.cmd('vsplit'); cmd.follow_link_or_create() end,
{ desc = 'nuwiki: follow link in vsplit (mouse)' }, bufnr)
map('n', '<MiddleMouse>', cmd.badd_link,
{ desc = 'nuwiki: badd link target' }, bufnr)
map('n', '<RightMouse>', '<C-o>',
{ desc = 'nuwiki: go back (mouse)', remap = true }, bufnr)
end
end
return M