phase 19: editor glue v2
Server-side:
- New `folding.rs` module with `folding_ranges(ast, total_lines)`.
Emits one fold per top-level heading (start → line before the
next same-or-higher-level heading, or EOF) plus one per top-level
list block. Nested headings fold inside their parent thanks to the
level-aware end-line computation; sublists fold implicitly via
their parent item's source span. `FoldingRangeKind::Region` is set
on every fold so collapsing UIs render them as section folds.
- `Backend::folding_range` handler wires the module into
`textDocument/foldingRange`; `ServerCapabilities.folding_range_provider`
advertises it.
- `folding::line_count` exposed for the handler and tests; treats a
trailing newline as a virtual empty line (the line LSP positions
use for EOF).
Editor glue (Neovim primary, Vim minimal):
- `lua/nuwiki/commands.lua` — async `workspace/executeCommand`
wrappers for every server command shipped through Phase 18. The
open-* family handles `{ uri }` responses by opening the file
(with `tab` / `split` variants). `check_links` and `find_orphans`
hand results to `setqflist` + `:copen` for quickfix-style review.
§13.1-deferred commands (`list.changeSymbol`, table rewriters,
`link.pasteWikilink/pasteUrl`, `colorize`) stub out with
`vim.notify` so users get a clear "not yet implemented" signal
instead of an LSP "unknown command" error.
- `lua/nuwiki/keymaps.lua` — buffer-local default mappings. Subgroups
(`list_editing`, `header_nav`, `diary`, `html_export`,
`text_objects`) flip independently via the new
`mappings.<group>` config. Heading promote/demote uses `g=`/`g-`
to avoid clobbering Vim's built-in `=`/`-` operators.
- `lua/nuwiki/textobjects.lua` — `ah`/`ih` (around/inside heading)
using a buffer scan for the heading-block boundary. The four
remaining text objects from SPEC §12.10 wait until §13.1 lands
the table/list rewriters they share infrastructure with.
- `lua/nuwiki/folding.lua` — pure regex `foldexpr` + `foldtext`
fallback for clients without `foldingRange`. Same heading-block
model as the server.
- `lua/nuwiki/ftplugin.lua` — single per-buffer attach entry point.
`folding = 'lsp'` (default) uses Neovim 0.11+'s `vim.lsp.foldexpr`,
falling back to the regex on older versions; `'expr'` forces the
fallback; `'off'` skips folding setup.
- `lua/nuwiki/config.lua` — extends defaults with `mappings = {...}`
(P10 keymap layer) and `folding` (P14 resolved).
- `ftplugin/nuwiki.vim` — declares every `:Vimwiki*` / `:Nuwiki*`
command from SPEC §12.10 by inlining `lua require(...).fn()`
bodies (no script-local function indirection so commands stay
callable after re-source). Plain-Vim users get only the buffer
options; they're expected to drive the LSP via vim-lsp / coc's
built-in commands.
Health check (§12.10 additions):
- `:checkhealth nuwiki` now reports the count of `executeCommand`
entries the server advertises, whether `foldingRange` capability
was negotiated, and whether the configured HTML output directory
exists + is writable. Default keymap subgroup status is also
surfaced.
Tests: 8 new in `phase19_folding.rs` covering empty docs, single
heading → EOF, sibling headings each getting their own fold,
nested heading bounded by parent, top-level list folds, single-line
heading non-fold, fold-kind classification, and `line_count`
semantics. Total 377 tests pass; fmt + clippy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
-- lua/nuwiki/commands.lua — Lua wrappers around every `nuwiki.*`
|
||||
-- `workspace/executeCommand` the server advertises. Phase 19 plumbing.
|
||||
--
|
||||
-- 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 position_params()
|
||||
return vim.lsp.util.make_position_params()
|
||||
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 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
|
||||
client.request(
|
||||
'workspace/executeCommand',
|
||||
{ command = command, arguments = arguments or {} },
|
||||
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,
|
||||
vim.api.nvim_get_current_buf()
|
||||
)
|
||||
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()
|
||||
return { vim.tbl_extend('force', { uri = buf_uri() }, position_params()) }
|
||||
end
|
||||
|
||||
local function uri_args()
|
||||
return { { uri = buf_uri() } }
|
||||
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
|
||||
|
||||
function M.wiki_ui_select()
|
||||
exec('nuwiki.wiki.select', { {} }, function(r)
|
||||
if type(r) ~= 'table' or #r == 0 then
|
||||
vim.notify('nuwiki: no wikis configured')
|
||||
return
|
||||
end
|
||||
vim.ui.select(
|
||||
r,
|
||||
{
|
||||
prompt = 'Pick a wiki',
|
||||
format_item = function(w)
|
||||
return string.format('%d. %s (%s)', (w.id or 0) + 1, w.name or '?', tostring(w.root))
|
||||
end,
|
||||
},
|
||||
function(choice)
|
||||
if not choice then return end
|
||||
exec(
|
||||
'nuwiki.wiki.openIndex',
|
||||
{ { wiki = choice.id } },
|
||||
function(rr) if rr and rr.uri then open_uri(rr.uri) end end
|
||||
)
|
||||
end
|
||||
)
|
||||
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')
|
||||
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 (§13.1) =====
|
||||
--
|
||||
-- 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
|
||||
|
||||
M.list_change_lvl = _not_yet(':VimwikiListChangeLvl')
|
||||
M.list_renumber = _not_yet(':VimwikiRenumber')
|
||||
M.list_remove_done = _not_yet(':VimwikiRemoveDone')
|
||||
M.table_insert = _not_yet(':VimwikiTable')
|
||||
M.table_move_column_left = _not_yet(':VimwikiTableMoveColumnLeft')
|
||||
M.table_move_column_right = _not_yet(':VimwikiTableMoveColumnRight')
|
||||
M.colorize = _not_yet(':VimwikiColorize')
|
||||
M.paste_link = _not_yet(':VimwikiPasteLink')
|
||||
M.paste_url = _not_yet(':VimwikiPasteUrl')
|
||||
|
||||
return M
|
||||
Reference in New Issue
Block a user