Files
nuwiki/lua/nuwiki/commands.lua
T
gffranco f0c51fbfcc
CI / cargo fmt --check (push) Successful in 28s
CI / cargo clippy (push) Successful in 36s
CI / cargo test (push) Successful in 40s
CI / editor keymaps (push) Successful in 1m26s
feat(generate): insert links/tags sections at the cursor line too
Extend the cursor-line insertion from :NuwikiTOC to the other buffer
list generators: :NuwikiGenerateLinks and :NuwikiGenerateTagLinks. They
appended at end-of-file; a fresh section now goes at the cursor line
(with a leading blank), matching :NuwikiTOC. An existing section is still
refreshed in place, and the auto_generate_*-on-save hooks are unaffected
(they only ever replace).

Shared section_insert() helper computes the insert position/block for all
three (cursor line clamped to the document, else the per-command fallback:
top of file for TOC, EOF for links/tags). Clients send the 0-based cursor
line; handlers parse it via parse_uri_line_arg and thread it through
links_edit / tag_links_edit. Rebuild variants pass None.

Tests: links_edit_inserts_at_cursor_line + tag_links_edit_inserts_at_cursor_line.
577 passed, clippy clean, keymap harnesses green. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 01:07:18 +00:00

1311 lines
44 KiB
Lua

-- lua/nuwiki/commands.lua — Lua wrappers around every `nuwiki.*`
-- `workspace/executeCommand` the server advertises.
--
-- Each public function is named after its `:Vimwiki*` / `:Nuwiki*`
-- counterpart so `ftplugin/nuwiki.vim` can wire them up declaratively.
-- All requests run asynchronously; commands that return a `{ uri }`
-- payload open the result in the current window (or a new tab, when
-- the variant is `*_tab`).
local M = {}
local function buf_uri(bufnr)
return vim.uri_from_bufnr(bufnr or 0)
end
local function find_client()
local fn = vim.lsp.get_clients or vim.lsp.get_active_clients
local clients = fn({ name = 'nuwiki', bufnr = 0 })
if #clients == 0 then
-- Try without bufnr filter for workspace-level commands.
clients = fn({ name = 'nuwiki' })
end
return clients[1]
end
local function position_params(client)
-- Neovim 0.12+ requires `position_encoding` explicitly; older
-- versions accept the no-arg form.
local encoding = client and client.offset_encoding or 'utf-16'
local ok, params = pcall(vim.lsp.util.make_position_params, 0, encoding)
if ok then return params end
-- Fallback for Neovim < 0.10 where the second arg wasn't a thing.
return vim.lsp.util.make_position_params()
end
local function exec(command, arguments, on_result)
local client = find_client()
if not client then
vim.notify('nuwiki: language server not attached', vim.log.levels.ERROR)
return
end
local handler = function(err, result)
if err then
vim.notify(
'nuwiki: ' .. command .. '' .. (err.message or vim.inspect(err)),
vim.log.levels.ERROR
)
return
end
if on_result then on_result(result) end
end
local bufnr = vim.api.nvim_get_current_buf()
local payload = { command = command, arguments = arguments or {} }
-- Neovim 0.12 deprecates `client.request(...)` in favour of the
-- method form `client:request(...)`. Prefer the method, fall back
-- on older Neovim where it isn't defined yet.
local req = client.request
if type(req) ~= 'function' then
return
end
if rawget(getmetatable(client) or {}, '__index') and client.request ~= nil then
-- Method-style works for both colon and dot calls on Neovim's
-- Client metatable, but pass `self` explicitly for the older
-- function-style signature compatibility.
client:request('workspace/executeCommand', payload, handler, bufnr)
else
client.request('workspace/executeCommand', payload, handler, bufnr)
end
end
local function open_uri(uri, tab)
if not uri then
return
end
local path = vim.uri_to_fname(uri)
if tab == 'tabdrop' then
vim.cmd('tab drop ' .. vim.fn.fnameescape(path))
return
end
local cmd
if tab == 'split' then
cmd = 'split'
elseif tab == 'vsplit' then
cmd = 'vsplit'
elseif tab == true then
cmd = 'tabedit'
else
cmd = 'edit'
end
vim.cmd(cmd .. ' ' .. vim.fn.fnameescape(path))
end
local function pos_args()
local client = find_client()
return { vim.tbl_extend('force', { uri = buf_uri() }, position_params(client)) }
end
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
--- Two-step behaviour matching vimwiki:
--- 1. First press on a bare word → wrap it as `[[word]]` and stop.
--- 2. Cursor is now inside `[[…]]`; next press follows the link.
--- 3. Press inside an existing `[[…]]` follows immediately.
--- "Not on a word" falls through to plain definition so users can
--- still chord a definition request anywhere.
--- Jump to the next / previous `[[…]]` on or after the cursor.
--- Public so `:VimwikiNextLink` / `:VimwikiPrevLink` can call it.
local function jump_link(direction)
local flags = direction > 0 and 'W' or 'bW'
local pos = vim.fn.searchpos([[\[\[]], flags)
if pos[1] > 0 then
vim.api.nvim_win_set_cursor(0, { pos[1], pos[2] - 1 })
end
end
function M.link_next() jump_link(1) end
function M.link_prev() jump_link(-1) end
--- `:VimwikiBaddLink` — add the link target's file to the buffer list
--- (`:badd`) without switching to it. Uses the LSP definition response
--- to find the file.
function M.badd_link()
local client = find_client()
if not client then
vim.notify('nuwiki: language server not attached', vim.log.levels.ERROR)
return
end
local params = position_params(client)
params.textDocument = { uri = buf_uri() }
local req = client.request
local handler = function(_, result)
local loc
if type(result) == 'table' then
loc = result[1] or result
end
local uri = type(loc) == 'table' and (loc.uri or loc.targetUri) or nil
if not uri then return end
local path = vim.uri_to_fname(uri)
pcall(vim.cmd, 'badd ' .. vim.fn.fnameescape(path))
vim.notify('nuwiki: added ' .. path, vim.log.levels.INFO)
end
local bufnr = vim.api.nvim_get_current_buf()
if type(req) == 'function' then
client:request('textDocument/definition', params, handler, bufnr)
end
end
--- `:VimwikiTabDropLink` — open the link target in a tab, reusing an
--- existing tab/window if the file is already shown (`:tab drop`). Uses
--- the LSP definition response to resolve the target file.
function M.follow_link_drop()
local client = find_client()
if not client then
vim.notify('nuwiki: language server not attached', vim.log.levels.ERROR)
return
end
local params = position_params(client)
params.textDocument = { uri = buf_uri() }
local req = client.request
local handler = function(_, result)
local loc
if type(result) == 'table' then
loc = result[1] or result
end
local uri = type(loc) == 'table' and (loc.uri or loc.targetUri) or nil
open_uri(uri, 'tabdrop')
end
local bufnr = vim.api.nvim_get_current_buf()
if type(req) == 'function' then
client:request('textDocument/definition', params, handler, bufnr)
end
end
function M.follow_link_or_create()
-- Already on a wikilink → follow it. Otherwise try to wrap the bare word
-- under the cursor as `[[word]]` and stop (a second press follows it,
-- matching vimwiki's review-then-follow flow). If there's nothing to wrap,
-- fall through to a plain definition request so a chord still works anywhere.
if not cursor_inside_wikilink() and wrap_cword_as_wikilink() then
return
end
vim.lsp.buf.definition()
end
-- ===== Wiki picker =====
function M.wiki_index(count)
local args = { {} }
if count and count > 0 then
args = { { wiki = count - 1 } } -- 1-indexed at the user level
end
exec('nuwiki.wiki.openIndex', args, function(r)
if r and r.uri then
open_uri(r.uri, r.tab)
end
end)
end
function M.wiki_tab_index(count)
local args = { {} }
if count and count > 0 then
args = { { wiki = count - 1 } }
end
exec('nuwiki.wiki.tabOpenIndex', args, function(r)
if r and r.uri then
open_uri(r.uri, true)
end
end)
end
-- Pick a wiki and open its index. The list is read straight from config
-- (no LSP round-trip) so this works from any buffer before a wiki file is
-- ever opened; opening the index then auto-starts the server.
-- :VimwikiShowVersion / :NuwikiShowVersion — echo the nuwiki version + editor.
function M.show_version()
local ver = vim.g.nuwiki_version or '?'
vim.api.nvim_echo({
{ 'nuwiki: ' .. ver .. '\n' },
{ 'Neovim: ' .. tostring(vim.version()) },
}, false, {})
end
-- vimwiki `auto_header`: on a new wiki page, insert a level-1 header derived
-- from the filename. Skips the wiki index + diary index pages, and only acts
-- on a genuinely empty buffer. Called from a BufNewFile autocmd.
function M.auto_header(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
if #lines > 1 or (lines[1] or '') ~= '' then
return -- not an empty new buffer
end
local file = vim.api.nvim_buf_get_name(bufnr)
local config = require('nuwiki.config')
local space = ' '
for _, c in ipairs(config.wiki_list()) do
local root = vim.fn.fnamemodify(vim.fn.expand(c.root), ':p')
if root:sub(-1) ~= '/' then root = root .. '/' end
if file:sub(1, #root) == root then
-- Skip the wiki index and the diary index pages.
local index_path = root .. c.idx .. c.ext
local diary_index_path = root .. c.diary_rel .. '/' .. c.diary_idx .. c.ext
if file == index_path or file == diary_index_path then
return
end
space = c.space or ' '
break
end
end
-- Convert links_space_char back to spaces for the title (vimwiki parity).
local title = vim.fn.fnamemodify(file, ':t:r')
if space ~= ' ' and space ~= '' then
title = title:gsub(vim.pesc(space), ' ')
end
vim.api.nvim_buf_set_lines(bufnr, 0, 1, false, { '= ' .. title .. ' =', '' })
end
-- :VimwikiSearch / :VWS {pattern} — search the current wiki's files (upstream
-- scopes to the wiki, not the editor CWD). Resolves the wiki whose root holds
-- the current file, else the first configured wiki, else CWD. Empty pattern
-- reuses the last search. Populates the location list.
function M.search(args)
local pat = (args == nil or args == '') and vim.fn.getreg('/') or args
if pat == '' then
vim.notify('nuwiki: no search pattern', vim.log.levels.WARN)
return
end
local config = require('nuwiki.config')
local file = vim.fn.expand('%:p')
local root, ext = '', '.wiki'
for _, w in ipairs(config.wiki_list()) do
local wroot = vim.fn.fnamemodify(vim.fn.expand(w.root), ':p')
if file:sub(1, #wroot) == wroot then
root, ext = wroot, w.ext or ext
break
end
end
if root == '' then
local first = config.wiki_list()[1]
if first then
root = vim.fn.fnamemodify(vim.fn.expand(first.root), ':p')
ext = first.ext or ext
end
end
if not ext:match('^%.') then ext = '.' .. ext end
local glob = root == '' and '**' or (vim.fn.fnameescape(root) .. '**/*' .. ext)
local ok = pcall(vim.cmd, 'lvimgrep /' .. vim.fn.escape(pat, '/') .. '/j ' .. glob)
if not ok then
vim.notify('nuwiki: no match for ' .. pat, vim.log.levels.WARN)
return
end
vim.cmd('lopen')
end
-- :VimwikiVar / :NuwikiVar [key [value]] — get/set the nuwiki client config
-- (upstream's vimwiki#vars#cmd). No arg prints the resolved setup options; one
-- arg prints that key; two+ sets it on the in-memory options (server-backed
-- settings need a restart).
function M.var(arg)
local parts = vim.split(arg or '', '%s+', { trimempty = true })
local opts = require('nuwiki.config').options
if #parts == 0 then
local keys = vim.tbl_keys(opts)
table.sort(keys)
local lines = {}
for _, k in ipairs(keys) do
lines[#lines + 1] = { string.format('%s = %s\n', k, vim.inspect(opts[k])) }
end
vim.api.nvim_echo(lines, false, {})
return
end
local key = parts[1]
if #parts == 1 then
vim.api.nvim_echo({ { string.format('%s = %s', key, vim.inspect(opts[key])) } }, false, {})
else
opts[key] = table.concat(parts, ' ', 2)
vim.api.nvim_echo({ {
string.format('set %s = %s (restart for server-backed settings)', key, vim.inspect(opts[key])),
} }, false, {})
end
end
function M.wiki_ui_select()
local wikis = require('nuwiki.config').wiki_list()
if #wikis == 0 then
vim.notify('nuwiki: no wikis configured', vim.log.levels.WARN)
return
end
vim.ui.select(
wikis,
{
prompt = 'Select a wiki',
format_item = function(w)
return string.format('%s (%s)', w.name, w.root)
end,
},
function(choice)
if not choice then return end
local path = choice.root .. '/' .. choice.idx .. choice.ext
vim.cmd('edit ' .. vim.fn.fnameescape(vim.fn.expand(path)))
end
)
end
function M.wiki_goto_page(name)
if not name or name == '' then
name = vim.fn.input('Goto page: ')
if name == '' then return end
end
exec('nuwiki.wiki.gotoPage', { { page = name } }, function(r)
if r and r.uri then open_uri(r.uri) end
end)
end
-- ===== Diary =====
-- `count` is vimwiki's diary `-count` — a wiki number (1-indexed at the user
-- level). When > 0 the server acts on that wiki (`wiki = count - 1`) instead
-- of the buffer's. next/prev ignore it.
local function _diary_open(cmd_name, tab)
return function(count)
local args = uri_args()
if count and count > 0 then
args[1].wiki = count - 1
end
exec(cmd_name, args, function(r)
if r and r.uri then open_uri(r.uri, tab) end
end)
end
end
M.diary_today = _diary_open('nuwiki.diary.openToday')
M.diary_today_tab = _diary_open('nuwiki.diary.openToday', true)
M.diary_yesterday = _diary_open('nuwiki.diary.openYesterday')
M.diary_tomorrow = _diary_open('nuwiki.diary.openTomorrow')
M.diary_index = _diary_open('nuwiki.diary.openIndex')
function M.diary_next()
exec('nuwiki.diary.next', uri_args(), function(r)
if r and r.uri then open_uri(r.uri) end
end)
end
function M.diary_prev()
exec('nuwiki.diary.prev', uri_args(), function(r)
if r and r.uri then open_uri(r.uri) end
end)
end
function M.diary_generate_index()
-- Returns a WorkspaceEdit; the server's executeCommand handler will
-- have already applied it via applyEdit, so there's nothing to do here.
exec('nuwiki.diary.generateIndex', uri_args())
end
-- ===== List + heading editing =====
local function _exec_pos(cmd_name)
return function() exec(cmd_name, pos_args()) end
end
M.toggle_list_item = _exec_pos('nuwiki.list.toggleCheckbox')
M.cycle_list_item = _exec_pos('nuwiki.list.cycleCheckbox')
-- `glp` cycles backward — same command, `reverse = true` in the payload.
function M.cycle_list_item_back()
local a = pos_args()
a[1].reverse = true
exec('nuwiki.list.cycleCheckbox', a)
end
M.reject_list_item = _exec_pos('nuwiki.list.rejectCheckbox')
-- Apply a per-line action across [l1, l2] (visual-range commands). Each call
-- targets its own line; the server's version-less single-line edits apply
-- independently as they return, so no conflict.
local function over_range(l1, l2, fn)
local save = vim.api.nvim_win_get_cursor(0)
for ln = l1, l2 do
vim.api.nvim_win_set_cursor(0, { ln, 0 })
fn()
end
pcall(vim.api.nvim_win_set_cursor, 0, save)
end
function M.toggle_list_item_range(l1, l2)
over_range(l1, l2, M.toggle_list_item)
end
function M.list_cycle_symbol_range(direction, l1, l2)
over_range(l1, l2, function() M.list_cycle_symbol(direction) end)
end
function M.reject_list_item_range(l1, l2)
over_range(l1, l2, M.reject_list_item)
end
M.heading_add_level = _exec_pos('nuwiki.heading.addLevel')
M.heading_remove_level = _exec_pos('nuwiki.heading.removeLevel')
function M.next_task()
exec('nuwiki.list.nextTask', pos_args(), function(loc)
if loc and loc.range and loc.range.start then
vim.api.nvim_win_set_cursor(0, {
loc.range.start.line + 1,
loc.range.start.character,
})
end
end)
end
-- ===== Generation + workspace =====
M.toc_generate = function()
-- Send the cursor line (0-based) so a fresh TOC is inserted there.
local line = vim.api.nvim_win_get_cursor(0)[1] - 1
exec('nuwiki.toc.generate', { { uri = buf_uri(), line = line } })
end
-- `:VimwikiGenerateLinks [rel_path]` — optional path scopes the links to a
-- subtree via the server's generateForPath; no arg = every page.
M.links_generate = function(path)
-- Cursor line (0-based) so a fresh section is inserted there.
local line = vim.api.nvim_win_get_cursor(0)[1] - 1
if path and path ~= '' then
exec('nuwiki.links.generateForPath', { { uri = buf_uri(), path = path, line = line } })
else
exec('nuwiki.links.generate', { { uri = buf_uri(), line = line } })
end
end
-- range>0 restricts the report to the current buffer's [l1, l2] lines
-- (`:'<,'>VimwikiCheckLinks`); otherwise the whole workspace.
function M.check_links(range, l1, l2)
local filter = type(range) == 'number' and range > 0
local cur = filter and vim.api.nvim_buf_get_name(0) or nil
exec('nuwiki.workspace.checkLinks', uri_args(), function(r)
if type(r) ~= 'table' or #r == 0 then
vim.notify('nuwiki: no broken links')
return
end
local items = {}
for _, b in ipairs(r) do
local fname = vim.uri_to_fname(b.uri)
local lnum = (b.range and b.range.start and b.range.start.line or 0) + 1
if not filter or (fname == cur and lnum >= l1 and lnum <= l2) then
table.insert(items, {
filename = fname,
lnum = lnum,
col = (b.range and b.range.start and b.range.start.character or 0) + 1,
text = (b.kind or '?') .. ': ' .. (b.message or ''),
})
end
end
if #items == 0 then
vim.notify('nuwiki: no broken links')
return
end
vim.fn.setqflist({}, ' ', { title = 'nuwiki broken links', items = items })
vim.cmd('copen')
end)
end
function M.find_orphans()
exec('nuwiki.workspace.findOrphans', uri_args(), function(r)
if type(r) ~= 'table' or #r == 0 then
vim.notify('nuwiki: no orphan pages')
return
end
local items = {}
for _, o in ipairs(r) do
table.insert(items, {
filename = vim.uri_to_fname(o.uri),
text = o.name or '?',
})
end
vim.fn.setqflist({}, ' ', { title = 'nuwiki orphans', items = items })
vim.cmd('copen')
end)
end
-- ===== Tags =====
function M.tags_search(query)
if not query or query == '' then
query = vim.fn.input('Search tags: ')
end
exec('nuwiki.tags.search', { { uri = buf_uri(), query = query } }, function(r)
if type(r) ~= 'table' or #r == 0 then
vim.notify('nuwiki: no tag matches')
return
end
local items = {}
for _, h in ipairs(r) do
table.insert(items, {
filename = vim.uri_to_fname(h.uri),
lnum = (h.range and h.range.start and h.range.start.line or 0) + 1,
col = (h.range and h.range.start and h.range.start.character or 0) + 1,
text = ':' .. h.name .. ': in ' .. (h.page or '?'),
})
end
vim.fn.setqflist({}, ' ', { title = 'nuwiki tag search', items = items })
vim.cmd('copen')
end)
end
function M.tags_generate_links(tag)
-- Cursor line (0-based) so a fresh section is inserted there.
local args = { uri = buf_uri(), line = vim.api.nvim_win_get_cursor(0)[1] - 1 }
if tag and tag ~= '' then
args.tag = tag
end
exec('nuwiki.tags.generateLinks', { args })
end
function M.tags_rebuild(all)
local a = uri_args()
a[1].all = all and true or false
exec('nuwiki.tags.rebuild', a, function(r)
if r and r.pages then
local scope = (r.all and (r.wikis or 0) ~= 1)
and string.format(' across %d wikis', r.wikis or 0) or ''
vim.notify(string.format('nuwiki: re-indexed %d page(s)%s', r.pages, scope))
end
end)
end
-- ===== Export =====
M.export_current = function() exec('nuwiki.export.currentToHtml', uri_args()) end
M.export_all = function() exec('nuwiki.export.allToHtml', uri_args()) end
M.export_all_force = function() exec('nuwiki.export.allToHtmlForce', uri_args()) end
M.export_rss = function() exec('nuwiki.export.rss', uri_args()) end
function M.export_browse()
exec('nuwiki.export.browse', uri_args(), function(r)
local url = r and r.browse
if not url then return end
-- Best-effort browser open. Falls back to a notification.
local opener
if vim.fn.has('mac') == 1 then
opener = 'open'
elseif vim.fn.has('unix') == 1 then
opener = 'xdg-open'
elseif vim.fn.has('win32') == 1 then
opener = 'start'
end
if opener then
vim.fn.jobstart({ opener, url }, { detach = true })
else
vim.notify('nuwiki: exported → ' .. url)
end
end)
end
-- ===== File ops =====
function M.delete_file()
local ans = vim.fn.input('Delete current page? [y/N] ')
if ans:lower() ~= 'y' then return end
exec('nuwiki.file.delete', { { uri = buf_uri() } })
end
function M.rename_file()
vim.lsp.buf.rename()
end
-- Cluster A — list rewriters.
-- `gl<Space>` — remove done items from the current list only. Passing the
-- cursor position scopes the server to the contiguous list block under it.
function M.list_remove_done()
exec('nuwiki.list.removeDone', pos_args())
end
-- `gL<Space>` — remove done items across the whole buffer (no position).
function M.list_remove_done_all()
exec('nuwiki.list.removeDone', uri_args())
end
-- `:'<,'>VimwikiRemoveDone` — remove done items only within the given line
-- range (1-indexed, inclusive; converted to the server's 0-indexed range).
function M.list_remove_done_range(l1, l2)
exec('nuwiki.list.removeDone', { { uri = buf_uri(), range = { l1 - 1, l2 - 1 } } })
end
-- `:[range]VimwikiRemoveDone[!]` dispatcher. `!` → whole buffer; an explicit
-- range (`range > 0`, e.g. `:'<,'>`) → that line span; otherwise the current
-- list under the cursor. Mirrors upstream's `-bang -range` form.
function M.remove_done(bang, range, l1, l2)
if bang then
M.list_remove_done_all()
elseif range and range > 0 then
M.list_remove_done_range(l1, l2)
else
M.list_remove_done()
end
end
-- `:VimwikiRemoveSingleCB` — strip the checkbox from the current item only.
function M.list_remove_checkbox()
exec('nuwiki.list.removeCheckbox', pos_args())
end
-- `:VimwikiRemoveCBInList` — strip checkboxes from every item in the list.
function M.list_remove_checkbox_in_list()
local args = pos_args()[1]
args.whole_list = true
exec('nuwiki.list.removeCheckbox', { args })
end
function M.list_renumber()
exec('nuwiki.list.renumber', pos_args())
end
function M.list_renumber_all()
local args = pos_args()[1]
args.whole_file = true
exec('nuwiki.list.renumber', { args })
end
function M.list_change_symbol(symbol, whole_list)
local args = pos_args()[1]
args.symbol = symbol
args.whole_list = whole_list and true or false
exec('nuwiki.list.changeSymbol', { args })
end
-- `:[range]VimwikiChangeSymbolTo {sym}` / `:[range]VimwikiListChangeSymbolI {sym}`
-- — upstream is -range -nargs=1; a range sets the marker on every list item in
-- the selection (single-item when no range, since l1==l2==cursor line).
function M.list_change_symbol_range(symbol, l1, l2)
over_range(l1, l2, function() M.list_change_symbol(symbol, false) end)
end
function M.list_change_level(delta, whole_subtree)
local args = pos_args()[1]
args.delta = delta
args.whole_subtree = whole_subtree and true or false
exec('nuwiki.list.changeLevel', { args })
end
-- `:[range]VimwikiListChangeLvl {increase|decrease} [plus_children]` — upstream
-- is `-range -nargs=+`. Direction is required; the optional second arg cascades
-- to each item's subtree; a range applies it to every list item in the range.
function M.list_change_lvl(line1, line2, direction, plus_children)
local delta = (direction == 'increase' or direction == 'indent') and 1 or -1
local n = tonumber(plus_children)
local children = n ~= nil and n ~= 0
over_range(line1, line2, function() M.list_change_level(delta, children) end)
end
--- Cycle the list marker forward (`direction = 1`) or backward
--- (`direction = -1`). Pure client-side: detects the current line's
--- marker via regex, picks the next symbol in vimwiki's canonical
--- order, and dispatches `list.changeSymbol`.
local _symbol_order = { '-', '*', '#', '1.', '1)', 'a)', 'A)', 'i)', 'I)' }
local function detect_current_symbol()
local line = vim.api.nvim_get_current_line()
-- Non-capturing indent; capture only the marker/separator we need.
local marker = line:match('^%s*([%-%*%#]) ')
if marker then return marker end
local sep = line:match('^%s*%d+([%.%)]) ')
if sep then return '1' .. sep end
if line:match('^%s*[ivxlcdm]+%) ') then return 'i)' end
if line:match('^%s*[IVXLCDM]+%) ') then return 'I)' end
if line:match('^%s*[a-z]+%) ') then return 'a)' end
if line:match('^%s*[A-Z]+%) ') then return 'A)' end
return nil
end
function M.list_cycle_symbol(direction)
local current = detect_current_symbol()
if not current then return end
local idx
for i, s in ipairs(_symbol_order) do
if s == current then idx = i break end
end
if not idx then return end
local next_idx = ((idx - 1 + (direction or 1)) % #_symbol_order) + 1
M.list_change_symbol(_symbol_order[next_idx], false)
end
--- `<C-L><C-M>` (insert mode) — toggle the checkbox on the current
--- item if any, OR insert `[ ]` after the marker if the line is a
--- plain list item without one. Pure client-side.
function M.list_toggle_or_add_checkbox()
local line = vim.api.nvim_get_current_line()
-- Already has a checkbox? Let the server's toggle handle it.
if line:match('%s*[%-%*%#]%s+%[[%sxXoO%.%-]%]') or
line:match('%s*%w+[%.%)]%s+%[[%sxXoO%.%-]%]') then
M.toggle_list_item()
return
end
-- Looks like a list item without a checkbox — insert `[ ] ` after
-- the marker.
local row = vim.api.nvim_win_get_cursor(0)[1]
local insert_at = nil
local function try(pattern)
local start_, finish = line:find(pattern)
if start_ then return finish end
end
insert_at = try('^%s*[%-%*%#] ') or try('^%s*%w+[%.%)] ')
if not insert_at then return end
local new_line = line:sub(1, insert_at) .. '[ ] ' .. line:sub(insert_at + 1)
vim.api.nvim_buf_set_lines(0, row - 1, row, false, { new_line })
end
-- ===== Pure-Lua table alignment =====
--
-- The LSP exposes `nuwiki.table.align` (see `M.table_align()` below),
-- but that path is async — by the time the workspace edit lands the
-- cursor has already been positioned by our keystrokes, which leaves
-- the cursor visually mid-cell after columns expand. For insert-mode
-- editing we replicate the alignment logic locally so we can realign
-- *before* placing the cursor, all in a single `vim.schedule` tick.
-- The algorithm matches `crates/nuwiki-lsp/src/commands.rs::ops::
-- render_aligned_table`.
local function is_table_row(line)
return line ~= nil and line:match('^%s*|.*|%s*$') ~= nil
end
local function table_cell_starts(line)
-- Returns 1-based byte positions of each `|` separator. Cell N spans
-- (starts[N], starts[N+1]); placing the cursor at 0-based byte column
-- `starts[i]` lands one position past the i-th `|` (the first space
-- of the cell that the `|` opens).
local out = {}
local pos = 0
while true do
local bar = line:find('|', pos + 1, true)
if not bar then break end
table.insert(out, bar)
pos = bar
end
return out
end
local function find_table_range(row)
-- 1-based inclusive line numbers spanning the table that contains `row`.
local total = vim.api.nvim_buf_line_count(0)
local s = row
while s > 1 and is_table_row(vim.fn.getline(s - 1)) do
s = s - 1
end
local e = row
while e < total and is_table_row(vim.fn.getline(e + 1)) do
e = e + 1
end
return s, e
end
local function parse_table_row(line)
local indent = line:match('^(%s*)') or ''
local body = line:sub(#indent + 1)
local segments = {}
local pos = 1
while true do
local bar = body:find('|', pos, true)
if not bar then
table.insert(segments, body:sub(pos))
break
end
table.insert(segments, body:sub(pos, bar - 1))
pos = bar + 1
end
-- Drop the empty piece before the opening `|` and the trailing piece
-- after the closing `|`.
if #segments > 0 and segments[1] == '' then table.remove(segments, 1) end
if #segments > 0 and segments[#segments]:match('^%s*$') then
table.remove(segments, #segments)
end
for i, c in ipairs(segments) do
segments[i] = c:match('^%s*(.-)%s*$') or ''
end
return indent, segments
end
local function is_separator_cell(s)
return s ~= '' and s:match('^[%-:%s]+$') ~= nil
end
local function is_separator_row(cells)
if #cells == 0 then return false end
for _, c in ipairs(cells) do
if not is_separator_cell(c) then return false end
end
return true
end
local function content_width(s)
-- Match the LSP-side `chars().count()` so user-triggered `gqq` and
-- the auto-realign here agree on column widths.
local ok, w = pcall(vim.fn.strchars, s, 1)
if ok and type(w) == 'number' then return w end
return #s
end
local function compute_table_widths(rows)
local widths = {}
local max_cols = 0
for _, r in ipairs(rows) do
if #r.cells > max_cols then max_cols = #r.cells end
end
for i = 1, max_cols do widths[i] = 1 end
for _, r in ipairs(rows) do
if not is_separator_row(r.cells) then
for i, c in ipairs(r.cells) do
local w = content_width(c)
if w > widths[i] then widths[i] = w end
end
end
end
return widths
end
local function render_table_row(indent, cells, widths, all_sep)
local out = { indent, '|' }
for i = 1, #widths do
local w = widths[i]
if all_sep then
table.insert(out, string.rep('-', w + 2))
else
local c = cells[i] or ''
local pad = w - content_width(c)
if pad < 0 then pad = 0 end
table.insert(out, ' ' .. c .. string.rep(' ', pad) .. ' ')
end
table.insert(out, '|')
end
return table.concat(out)
end
local function align_table_at(row)
local s, e = find_table_range(row)
local lines = vim.api.nvim_buf_get_lines(0, s - 1, e, false)
local rows = {}
for _, l in ipairs(lines) do
local indent, cells = parse_table_row(l)
table.insert(rows, { indent = indent, cells = cells })
end
local widths = compute_table_widths(rows)
if #widths == 0 then return end
local new_lines = {}
for _, r in ipairs(rows) do
table.insert(new_lines, render_table_row(r.indent, r.cells, widths, is_separator_row(r.cells)))
end
vim.api.nvim_buf_set_lines(0, s - 1, e, false, new_lines)
end
-- Public helpers dispatched via `<Cmd>` from the `<expr>` mappings.
-- The mappings return a `<Cmd>lua require('nuwiki.commands')._foo(…)<CR>`
-- keystroke so these run synchronously inside the same keypress —
-- crucial when the user types something *immediately* after Tab/<CR>,
-- which would otherwise land at the pre-jump cursor (the bug a prior
-- `vim.schedule`-based version shipped with).
function M._table_jump_to_cell(row, target_idx)
align_table_at(row)
local new_line = vim.fn.getline(row)
local starts = table_cell_starts(new_line)
if target_idx < 1 or target_idx >= #starts then return end
-- starts[i] is the 1-based byte position of the i-th `|`. Placing the
-- cursor at 0-based byte `starts[i]` lands on the space immediately
-- after that pipe — the first character inside the cell it opens.
vim.api.nvim_win_set_cursor(0, { row, starts[target_idx] })
end
function M._table_insert_row_below(row)
align_table_at(row)
local new_line = vim.fn.getline(row)
local starts = table_cell_starts(new_line)
if #starts < 2 then return end
local indent = new_line:match('^(%s*)') or ''
local cell_count = #starts - 1
local fresh = indent .. '|' .. string.rep(' |', cell_count)
vim.api.nvim_buf_set_lines(0, row, row, false, { fresh })
vim.api.nvim_win_set_cursor(0, { row + 1, #indent + 1 })
end
--- Smart `<CR>` for insert mode — vimwiki parity for `:VimwikiReturn`.
--- Used as an `<expr>` keymap so the function is side-effect-free on
--- the buffer (textlock is active). The table-row branch hands off to
--- `_table_insert_row_below` via `<Cmd>` so the new row is in place
--- before the user's next keystroke is processed.
function M.smart_return()
local line = vim.api.nvim_get_current_line()
local function tc(s) return vim.api.nvim_replace_termcodes(s, true, false, true) end
local cr = tc('<CR>')
local esc = tc('<Esc>')
-- Accept the completion (plain <CR>) when the popup menu is open, like upstream.
if vim.fn.pumvisible() == 1 then return cr end
if is_table_row(line) then
local row = vim.api.nvim_win_get_cursor(0)[1]
return string.format(
'<Cmd>lua require("nuwiki.commands")._table_insert_row_below(%d)<CR>',
row
)
end
-- List item with marker?
local indent, marker, after = line:match('^(%s*)([%-%*%#])%s(.*)$')
if not marker then
local n
indent, n, marker, after = line:match('^(%s*)(%d+)([%.%)])%s(.*)$')
if marker then marker = n .. marker end
end
if marker then
local cb = after:match('^%[[%sxXoO%.%-]%]%s*(.*)$')
local body = cb or after
if body:match('^%s*$') then
-- Empty item → drop to normal, clear the line, re-enter insert,
-- then a fresh newline. Indent is dropped (matches vimwiki's
-- "break out of the list" behaviour on an empty bullet).
return esc .. '0DA' .. cr
end
-- nvim defaults `autoindent=on`, which inserts the previous line's
-- indent right after the `<CR>` we return. If we then add our own
-- `indent`, the new bullet is pushed one level deeper than the one
-- the user is continuing. Skip our copy whenever any auto-indent
-- mechanism is active and let Vim/Nvim insert the indent for us.
local auto = vim.bo.autoindent or vim.bo.smartindent or vim.bo.cindent
or (vim.bo.indentexpr ~= nil and vim.bo.indentexpr ~= '')
local effective_indent = auto and '' or indent
return cr .. effective_indent .. marker .. ' ' .. (cb and '[ ] ' or '')
end
return cr
end
-- Insert-mode `<S-CR>`: continue the current list item on a new line WITHOUT a
-- new marker, indented to align under the item's text — vimwiki's "multiline
-- list item" (`VimwikiReturn 2 2`). Off a list item it's a plain `<CR>`.
function M.smart_shift_return()
local line = vim.api.nvim_get_current_line()
local cr = vim.api.nvim_replace_termcodes('<CR>', true, false, true)
if vim.fn.pumvisible() == 1 then return cr end
local indent, marker, after = line:match('^(%s*)([%-%*%#])%s(.*)$')
if not marker then
local n
indent, n, marker, after = line:match('^(%s*)(%d+)([%.%)])%s(.*)$')
if marker then marker = n .. marker end
end
if not marker then
return cr
end
-- Align under the text: marker width + 1 space (+ 4 for a `[ ] ` checkbox).
local has_cb = after:match('^%[[%sxXoO%.%-]%]') ~= nil
local pad = #marker + 1 + (has_cb and 4 or 0)
-- Mirror smart_return's auto-indent handling: when an auto-indent mechanism
-- is active, Vim already re-inserts the line's indent after `<CR>`.
local auto = vim.bo.autoindent or vim.bo.smartindent or vim.bo.cindent
or (vim.bo.indentexpr ~= nil and vim.bo.indentexpr ~= '')
return cr .. (auto and '' or indent) .. string.rep(' ', pad)
end
-- :VimwikiReturn / :NuwikiReturn — the command form of the smart `<CR>`
-- continuation (upstream's mapping-driven VimwikiReturn). Enters insert at end
-- of line and triggers the buffer-local `<CR>` mapping (smart_return). Args
-- mirror upstream's mode flags and are unused (continuation is inferred).
function M.vimwiki_return()
vim.api.nvim_feedkeys(
vim.api.nvim_replace_termcodes('A<CR>', true, false, true), 'm', false)
end
--- Insert-mode `<Tab>` / `<S-Tab>` table navigation — vimwiki parity.
--- Used as `<expr>` keymaps so we can fall back to a literal `<Tab>` /
--- `<S-Tab>` when the cursor isn't on a table row. Inside a row both
--- variants align the table first (so columns stay tight as cells
--- grow), then jump to the adjacent cell; `<Tab>` past the final cell
--- adds a fresh row below, `<S-Tab>` past the first cell is a no-op.
function M.smart_tab()
local line = vim.api.nvim_get_current_line()
local function tc(s) return vim.api.nvim_replace_termcodes(s, true, false, true) end
local tab = tc('<Tab>')
if not is_table_row(line) then
return tab
end
local cell_starts = table_cell_starts(line)
if #cell_starts < 2 then return tab end
local cur = vim.api.nvim_win_get_cursor(0)
local row, col = cur[1], cur[2]
local current_idx = 1
for i, start in ipairs(cell_starts) do
if start <= col + 1 then current_idx = i end
end
local target_idx = current_idx + 1
if target_idx < #cell_starts then
return string.format(
'<Cmd>lua require("nuwiki.commands")._table_jump_to_cell(%d, %d)<CR>',
row,
target_idx
)
end
-- Past the final cell. Prefer cycling into the next existing row
-- (skipping any separator); only insert a fresh row when we're on the
-- table's last row.
local _, e_row = find_table_range(row)
local next_row = row + 1
while next_row <= e_row do
local _, next_cells = parse_table_row(vim.fn.getline(next_row))
if not is_separator_row(next_cells) then
return string.format(
'<Cmd>lua require("nuwiki.commands")._table_jump_to_cell(%d, %d)<CR>',
next_row,
1
)
end
next_row = next_row + 1
end
return string.format(
'<Cmd>lua require("nuwiki.commands")._table_insert_row_below(%d)<CR>',
row
)
end
function M.smart_shift_tab()
local line = vim.api.nvim_get_current_line()
local function tc(s) return vim.api.nvim_replace_termcodes(s, true, false, true) end
local stab = tc('<S-Tab>')
if not is_table_row(line) then
return stab
end
local cell_starts = table_cell_starts(line)
if #cell_starts < 2 then return stab end
local cur = vim.api.nvim_win_get_cursor(0)
local row, col = cur[1], cur[2]
local current_idx
for i, start in ipairs(cell_starts) do
if start <= col + 1 then current_idx = i end
end
if not current_idx or current_idx <= 1 then return stab end
local target_idx = current_idx - 1
return string.format(
'<Cmd>lua require("nuwiki.commands")._table_jump_to_cell(%d, %d)<CR>',
row,
target_idx
)
end
-- Cluster B — table rewriters.
function M.table_insert(cols, rows)
cols = tonumber(cols) or 5
rows = tonumber(rows) or 2
local p = pos_args()[1]
p.cols = cols
p.rows = rows
exec('nuwiki.table.insert', { p })
end
function M.table_align()
exec('nuwiki.table.align', pos_args())
end
-- Upstream `gqq` (AlignQ) / `gww` (AlignW): on a table row, align the table;
-- otherwise fall back to the native Vim format command (`normal! gqq`/`gww`).
-- `normal!` bypasses our mapping so there's no recursion.
function M.table_align_or_cmd(cmd)
if is_table_row(vim.api.nvim_get_current_line()) then
M.table_align()
else
vim.cmd('normal! ' .. cmd)
end
end
function M.table_move_column_left()
local p = pos_args()[1]
p.dir = 'left'
exec('nuwiki.table.moveColumn', { p })
end
function M.table_move_column_right()
local p = pos_args()[1]
p.dir = 'right'
exec('nuwiki.table.moveColumn', { p })
end
--- `:VimwikiColorize {color}` — wrap the word (or visual selection)
--- under cursor in an inline span tag. Matches vimwiki's behaviour:
--- the template is `<span style="color:%c">%s</span>` with `%c`
--- replaced by the user-supplied colour and `%s` by the wrapped text.
--- Pure-Lua; no server roundtrip needed.
-- Read the last visual selection from the `'<`/`'>` marks. Only meaningful
-- when the caller knows it's acting on a selection (colorize's `visual` flag);
-- we deliberately don't gate on vim.fn.visualmode() here since that reflects
-- session state, not whether the marks are the selection we want right now.
local function visual_selection_range()
local s = vim.api.nvim_buf_get_mark(0, '<')
local e = vim.api.nvim_buf_get_mark(0, '>')
if s[1] == 0 or e[1] == 0 then return nil end
return s[1], s[2], e[1], e[2]
end
-- colorize(color, visual): wrap text in an inline colour span.
--
-- `visual` MUST be passed only by the visual-mode mapping — it means "act on
-- the `'<`/`'>` selection". We can't infer it from `vim.fn.visualmode()`,
-- which returns the *last* visual mode used in the session (with stale marks),
-- so the normal-mode command / mapping would otherwise wrap whatever was last
-- selected instead of the word under the cursor.
function M.colorize(color, visual)
if not color or color == '' then
color = vim.fn.input('Colour: ')
if color == '' then return end
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local line = vim.api.nvim_get_current_line()
-- vimwiki `color_tag_template`: split on __CONTENT__ into open/close, with
-- __COLORFG__ replaced by the colour. Configurable via the setup option /
-- g:nuwiki_color_tag_template.
local tpl = (require('nuwiki.config').options or {}).color_tag_template
or vim.g.nuwiki_color_tag_template
or '<span style="color:__COLORFG__">__CONTENT__</span>'
-- Function replacement returns the colour verbatim (a string replacement
-- would treat `%` specially).
tpl = tpl:gsub('__COLORFG__', function() return color end)
local open_tag, close_tag = tpl:match('^(.*)__CONTENT__(.*)$')
if not open_tag then
open_tag, close_tag = string.format('<span style="color:%s">', color), '</span>'
end
if visual then
local sl, sc, el, ec = visual_selection_range()
if sl and sl == el then
-- Single-line visual selection.
local sline = vim.api.nvim_buf_get_lines(0, sl - 1, sl, false)[1] or ''
local new_line = sline:sub(1, sc)
.. open_tag
.. sline:sub(sc + 1, ec + 1)
.. close_tag
.. sline:sub(ec + 2)
vim.api.nvim_buf_set_lines(0, sl - 1, sl, false, { new_line })
vim.fn['nuwiki#colors#refresh']()
return
end
-- Multi-line or no usable selection: fall through to the cword.
end
-- Wrap the cword.
local cword = vim.fn.expand('<cword>')
if cword == '' then return end
local col = vim.api.nvim_win_get_cursor(0)[2]
local left = col
while left > 0 and line:sub(left, left):match('[%w_%-]') do
left = left - 1
end
if not line:sub(left + 1, left + 1):match('[%w_%-]') then
left = left + 1
end
local right = left + 1
while right <= #line and line:sub(right, right):match('[%w_%-]') do
right = right + 1
end
local before = line:sub(1, left)
local word = line:sub(left + 1, right - 1)
local after = line:sub(right)
local new_line = before .. open_tag .. word .. close_tag .. after
vim.api.nvim_buf_set_lines(0, row - 1, row, false, { new_line })
vim.fn['nuwiki#colors#refresh']()
end
-- Cluster C — link helpers.
function M.paste_link()
exec('nuwiki.link.pasteWikilink', pos_args())
end
function M.paste_url()
exec('nuwiki.link.pasteUrl', pos_args())
end
-- Wrap the single-line `'<`/`'>` visual selection as `[[selection]]`.
local function wrap_visual_as_wikilink()
local sl, sc, el, ec = visual_selection_range()
if not (sl and sl == el) then return end
local line = vim.api.nvim_buf_get_lines(0, sl - 1, sl, false)[1] or ''
local sel = line:sub(sc + 1, ec + 1)
if sel == '' or sel:match('^%[%[.*%]%]$') then return end
local new = line:sub(1, sc) .. '[[' .. sel .. ']]' .. line:sub(ec + 2)
vim.api.nvim_buf_set_lines(0, sl - 1, sl, false, { new })
end
--- Vimwiki `:VimwikiNormalizeLink [1]` — wrap text as `[[…]]` without
--- following. With `visual` (upstream's `1` arg / the `x`-mode `+` mapping)
--- wrap the selection; otherwise the word at the cursor.
function M.normalize_link(visual)
if visual then
wrap_visual_as_wikilink()
return
end
if cursor_inside_wikilink() then return end
wrap_cword_as_wikilink()
end
--- Vimwiki `:VimwikiCatUrl` — echo the `file://` URL of the current
--- page's HTML export. The server computes the path from the wiki's
--- html config; we surface it via `:echo` like upstream.
function M.cat_url()
exec('nuwiki.link.catUrl', uri_args(), function(url)
if type(url) == 'string' and url ~= '' then
vim.api.nvim_echo({ { url } }, false, {})
end
end)
end
return M