Files
nuwiki/lua/nuwiki/commands.lua
T
gffranco 8ab6015405
CI / cargo fmt --check (push) Successful in 33s
CI / cargo clippy (push) Successful in 23s
CI / cargo test (push) Successful in 33s
CI / editor keymaps (push) Successful in 1m25s
Major clean up and improvements to vimwiki compatibility and documentation.
Reviewed-on: #1
2026-05-30 18:35:40 +00:00

997 lines
32 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)
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
function M.follow_link_or_create()
if cursor_inside_wikilink() then
vim.lsp.buf.definition()
return
end
if wrap_cword_as_wikilink() then
-- Wrapped — leave the user inside the new `[[…]]` and stop. A
-- second `<CR>` follows it, matching vimwiki's review-then-follow
-- flow.
return
end
-- No word under cursor either — fall through.
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.
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 =====
local function _diary_open(cmd_name)
return function()
exec(cmd_name, uri_args(), function(r)
if r and r.uri then open_uri(r.uri) end
end)
end
end
M.diary_today = _diary_open('nuwiki.diary.openToday')
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')
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() exec('nuwiki.toc.generate', uri_args()) end
M.links_generate = function() exec('nuwiki.links.generate', uri_args()) end
function M.check_links()
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
table.insert(items, {
filename = vim.uri_to_fname(b.uri),
lnum = (b.range and b.range.start and b.range.start.line or 0) + 1,
col = (b.range and b.range.start and b.range.start.character or 0) + 1,
text = (b.kind or '?') .. ': ' .. (b.message or ''),
})
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)
local args = { uri = buf_uri() }
if tag and tag ~= '' then
args.tag = tag
end
exec('nuwiki.tags.generateLinks', { args })
end
function M.tags_rebuild()
exec('nuwiki.tags.rebuild', uri_args(), function(r)
if r and r.pages then
vim.notify(string.format('nuwiki: re-indexed %d page(s)', r.pages))
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
-- ===== Deferred placeholders =====
--
-- These commands aren't implemented on the server yet. Stub them so the
-- `:Vimwiki*` compat surface exists and users get a clear message
-- instead of "unknown command".
local function _not_yet(name)
return function()
vim.notify(
'nuwiki: ' .. name .. ' is not yet implemented',
vim.log.levels.WARN
)
end
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
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
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
-- Vimwiki's `:VimwikiListChangeLvl decrease|increase 0` keeps a single
-- entry point. Forward to changeLevel.
function M.list_change_lvl(direction)
local delta = (direction == 'increase' or direction == 'indent') and 1 or -1
M.list_change_level(delta, false)
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()
-- Match optional indent, marker, then whitespace.
local indent, marker = line:match('^(%s*)([%-%*%#]) ')
if marker then return marker end
local n
indent, n, marker = line:match('^(%s*)(%d+)([%.%)]) ')
if marker then return '1' .. marker end
-- Roman lower
indent, marker = line:match('^(%s*)([ivxlcdm]+)%) ')
if marker then return 'i)' end
-- Roman upper
indent, marker = line:match('^(%s*)([IVXLCDM]+)%) ')
if marker then return 'I)' end
-- Alpha lower (single letter or short run)
indent, marker = line:match('^(%s*)([a-z]+)%) ')
if marker then return 'a)' end
indent, marker = line:match('^(%s*)([A-Z]+)%) ')
if marker then return 'A)' end
-- Suppress unused warning
local _ = indent
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>')
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 `<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 3
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
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.
local function visual_selection_range()
local mode = vim.fn.visualmode()
if mode == '' then return nil end
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
function M.colorize(color)
if not color or color == '' then
color = vim.fn.input('Colour: ')
if color == '' then return end
end
local sl, sc, el, ec = visual_selection_range()
local row = vim.api.nvim_win_get_cursor(0)[1]
local line = vim.api.nvim_get_current_line()
local open_tag = string.format('<span style="color:%s">', color)
local close_tag = '</span>'
if sl and sl == el then
-- Single-line visual selection.
local new_line = line:sub(1, sc)
.. open_tag
.. line:sub(sc + 1, ec + 1)
.. close_tag
.. line:sub(ec + 2)
vim.api.nvim_buf_set_lines(0, sl - 1, sl, false, { new_line })
return
end
-- Fall back to 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 })
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
--- Vimwiki `:VimwikiNormalizeLink` — wrap the word at cursor as
--- `[[word]]` without following. The `<CR>` two-step does the same
--- thing under the hood; expose it as a standalone command + keymap
--- for users who want to bind it to `+` per vimwiki defaults.
function M.normalize_link()
if cursor_inside_wikilink() then return end
wrap_cword_as_wikilink()
end
return M