5375e30bda
CI / editor tests (push) Successful in 46s
The server lexer (nuwiki-rs) splits table cells on a backslash-escaped `\|`
(it can't skip it yet without leaving a stray backslash in the output), but
the client's cell_bar_positions was skipping `\|`. That made the editor
auto-align keep `a\|b` as one cell while HTML export split it into two.
Drop the `\|` case from both clients so buffer alignment and export agree.
`[[…]]` / `{{…}}` / `` `code` `` pipes are still treated as literal. A
coordinated `\|` follow-up (server unescaping + client re-adding the skip)
is tracked in nuwiki-rs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1322 lines
45 KiB
Lua
1322 lines
45 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.11+ requires the position encoding explicitly (the no-arg form is
|
|
-- deprecated); pass the current window + the client's negotiated encoding.
|
|
local encoding = client and client.offset_encoding or 'utf-16'
|
|
return vim.lsp.util.make_position_params(0, encoding)
|
|
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 {} }
|
|
-- The method form `client:request(...)` is the correct, non-deprecated call
|
|
-- on every supported Neovim (0.11+); the old `client.request(...)` function
|
|
-- form was deprecated in its favour.
|
|
if type(client.request) ~= 'function' then
|
|
return
|
|
end
|
|
client:request('workspace/executeCommand', payload, handler, bufnr)
|
|
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
|
|
|
|
-- Find the word around 0-based cursor `col` in `line`. Returns `left` (0-based
|
|
-- index of the char before the word start) and `right` (1-based index one past
|
|
-- the word's last char), so `word = line:sub(left + 1, right - 1)`. A "word" is
|
|
-- a run of `[%w_-]`.
|
|
local function word_boundaries(line, col)
|
|
local left = col
|
|
while left > 0 and line:sub(left, left):match('[%w_-]') do
|
|
left = left - 1
|
|
end
|
|
-- `left` sits one before the word start; nudge forward if it's not on a
|
|
-- word char (cursor was already at the very start).
|
|
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
|
|
return left, right
|
|
end
|
|
|
|
local function wrap_cword_as_wikilink()
|
|
local cword = vim.fn.expand('<cword>')
|
|
if cword == '' then return false end
|
|
-- Replace just this `<cword>` occurrence 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()
|
|
local left, right = word_boundaries(line, col)
|
|
local word = line:sub(left + 1, right - 1)
|
|
if word == '' then return false end
|
|
local new_line = line:sub(1, left) .. '[[' .. word .. ']]' .. line:sub(right)
|
|
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, left + 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 wikis = config.wiki_list()
|
|
local root, ext = '', '.wiki'
|
|
for _, w in ipairs(wikis) 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 = wikis[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, err = pcall(vim.cmd, 'lvimgrep /' .. vim.fn.escape(pat, '/') .. '/j ' .. glob)
|
|
if not ok then
|
|
-- E480 = no match: a normal "nothing found", not an error. Anything else
|
|
-- (bad pattern, unreadable dir) is surfaced verbatim. Mirrors the VimL
|
|
-- twin's `catch /E480/`.
|
|
if type(err) == 'string' and err:match('E480') then
|
|
vim.notify('nuwiki: no match for ' .. pat, vim.log.levels.WARN)
|
|
else
|
|
vim.notify('nuwiki: ' .. tostring(err), vim.log.levels.ERROR)
|
|
end
|
|
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 the LSP server's implementation in nuwiki-rs.
|
|
|
|
local is_table_row = require('nuwiki.util').is_table_row
|
|
|
|
-- 1-based byte positions of the cell-separator `|` characters in `s`. Pipes
|
|
-- that are literal cell content are skipped: those inside `[[…]]` wikilinks,
|
|
-- `{{…}}` transclusions, and `` `…` `` inline code. This keeps a
|
|
-- `[[url|title]]` link in one cell instead of splitting on its internal pipe.
|
|
--
|
|
-- Backslash-escaped `\|` is intentionally NOT skipped, to match the server
|
|
-- lexer (nuwiki-rs), which can't skip it yet without leaving a stray backslash
|
|
-- in the output. See nuwiki-rs issue for the coordinated `\|` follow-up.
|
|
local function cell_bar_positions(s)
|
|
local out = {}
|
|
local i = 1
|
|
local n = #s
|
|
while i <= n do
|
|
local c = s:sub(i, i)
|
|
if c == '`' then
|
|
local close = s:find('`', i + 1, true)
|
|
i = (close or n) + 1
|
|
elseif s:sub(i, i + 1) == '[[' then
|
|
local close = s:find(']]', i + 2, true)
|
|
i = close and close + 2 or n + 1
|
|
elseif s:sub(i, i + 1) == '{{' then
|
|
local close = s:find('}}', i + 2, true)
|
|
i = close and close + 2 or n + 1
|
|
elseif c == '|' then
|
|
table.insert(out, i)
|
|
i = i + 1
|
|
else
|
|
i = i + 1
|
|
end
|
|
end
|
|
return out
|
|
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).
|
|
return cell_bar_positions(line)
|
|
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
|
|
for _, bar in ipairs(cell_bar_positions(body)) do
|
|
table.insert(segments, body:sub(pos, bar - 1))
|
|
pos = bar + 1
|
|
end
|
|
table.insert(segments, body:sub(pos))
|
|
-- 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
|
|
|
|
-- Parse a list-item line into indent / marker / trailing text. Handles
|
|
-- `-`/`*`/`#` bullets and numbered `N.`/`N)` markers (the marker includes the
|
|
-- number). `marker` is nil when `line` isn't a list item.
|
|
local function parse_list_marker(line)
|
|
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
|
|
return indent, marker, after
|
|
end
|
|
|
|
-- True when any auto-indent mechanism is active. nvim then re-inserts the
|
|
-- previous line's indent after a returned `<CR>`, so we must not also add our
|
|
-- own copy (which would push the continuation one level too deep).
|
|
local function has_auto_indent()
|
|
return vim.bo.autoindent or vim.bo.smartindent or vim.bo.cindent
|
|
or (vim.bo.indentexpr ~= nil and vim.bo.indentexpr ~= '')
|
|
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 = parse_list_marker(line)
|
|
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
|
|
-- Skip our own indent copy when auto-indent is active (see has_auto_indent).
|
|
local effective_indent = has_auto_indent() 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 = parse_list_marker(line)
|
|
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 (see has_auto_indent).
|
|
return cr .. (has_auto_indent() 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, right = word_boundaries(line, col)
|
|
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
|