Files
nuwiki/lua/nuwiki/commands.lua
T
gffranco 21b485c91b Remove stale phase/spec references from code comments
Strip "Phase N", "SPEC §X.Y", and "v1.x" planning labels from comments
across the Rust crates and editor layers now that those tracking
artifacts are gone. Comments only — no logic, strings, or test names
touched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:25:51 -03: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 — see SPEC §13.1',
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