phase 19: editor glue v2
CI / cargo fmt --check (push) Successful in 21s
CI / cargo clippy (push) Successful in 1m8s
CI / cargo test (push) Successful in 1m18s

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:
2026-05-11 21:57:05 +00:00
parent 106268d488
commit 63e5b6d514
13 changed files with 1029 additions and 31 deletions
+348
View File
@@ -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
+18 -1
View File
@@ -1,6 +1,7 @@
-- lua/nuwiki/config.lua — defaults and user-config merge.
--
-- Schema mirrors SPEC §7.5. Keep this aligned with `doc/nuwiki.txt`.
-- Schema mirrors SPEC §7.5 + §12.11. Keep this aligned with
-- `doc/nuwiki.txt`.
local M = {}
@@ -9,6 +10,22 @@ M.defaults = {
file_extension = '.wiki',
syntax = 'vimwiki',
log_level = 'warn',
-- Phase 19: per-buffer glue. Each subgroup can be flipped off
-- independently to suppress the corresponding keymaps.
mappings = {
enabled = true,
list_editing = true,
header_nav = true,
diary = true,
html_export = true,
text_objects = true,
},
-- Phase 19: folding. `'lsp'` uses the server's `foldingRange`
-- provider (Neovim 0.11+ wires it automatically). `'expr'` falls
-- back to a regex-based `foldexpr`. `'off'` skips folding setup.
folding = 'lsp',
}
M.options = vim.deepcopy(M.defaults)
+42
View File
@@ -0,0 +1,42 @@
-- lua/nuwiki/folding.lua — `foldexpr` fallback when the LSP isn't
-- attached. Same heading-block model as the server-side `foldingRange`
-- provider in `crates/nuwiki-lsp/src/folding.rs`, but driven from a
-- pure regex over `getline()` so it works without a running server.
--
-- Activated by `setlocal foldmethod=expr foldexpr=v:lua.require'nuwiki.folding'.expr()`
-- in `ftplugin/nuwiki.vim` when `folding` is enabled in the user config.
local M = {}
--- Heading level from one line of source. Returns 0 for non-headings.
local function heading_level(line)
local lead = line:match('^%s*(=+)%s')
if not lead then return 0 end
local lvl = #lead
if lvl > 6 then return 0 end
-- Require a matching trailing run of `=`s.
local trail = line:match('%s(=+)%s*$')
if not trail or #trail ~= lvl then return 0 end
return lvl
end
--- `foldexpr` body. Returns a fold-marker string per `:help fold-expr`.
function M.expr()
local lnum = vim.v.lnum
local line = vim.fn.getline(lnum)
local lvl = heading_level(line)
if lvl > 0 then
return '>' .. lvl
end
return '='
end
--- Build a heading-only `foldtext` showing the heading title trimmed.
function M.foldtext()
local fs = vim.v.foldstart
local line = vim.fn.getline(fs):gsub('=', ''):gsub('^%s+', ''):gsub('%s+$', '')
local count = vim.v.foldend - vim.v.foldstart + 1
return string.format('▸ %s … (%d lines)', line, count)
end
return M
+57
View File
@@ -0,0 +1,57 @@
-- lua/nuwiki/ftplugin.lua — per-buffer hookup invoked by
-- `ftplugin/nuwiki.vim`. Centralises the Lua side of Phase 19 so the
-- VimL ftplugin stays a one-liner regardless of which subgroups are
-- enabled.
local M = {}
local function setup_folding(bufnr, folding_mode)
if folding_mode == 'off' then
return
end
if folding_mode == 'expr' then
vim.api.nvim_set_option_value('foldmethod', 'expr', { buf = bufnr })
vim.api.nvim_set_option_value(
'foldexpr',
'v:lua.require("nuwiki.folding").expr()',
{ buf = bufnr }
)
vim.api.nvim_set_option_value(
'foldtext',
'v:lua.require("nuwiki.folding").foldtext()',
{ buf = bufnr }
)
return
end
-- folding_mode == 'lsp' (default): rely on the server's
-- `foldingRange` capability. Neovim 0.11+ enables it automatically
-- via `vim.lsp.foldexpr`. Older versions get the regex fallback.
if vim.fn.has('nvim-0.11') == 1 and vim.lsp.foldexpr then
vim.api.nvim_set_option_value('foldmethod', 'expr', { buf = bufnr })
vim.api.nvim_set_option_value(
'foldexpr',
'v:lua.vim.lsp.foldexpr()',
{ buf = bufnr }
)
else
-- Pre-0.11 — degrade to the regex fallback so users still get folds.
setup_folding(bufnr, 'expr')
end
end
function M.attach(bufnr)
bufnr = bufnr or 0
local config = require('nuwiki.config')
local opts = config.options or config.defaults
if opts.mappings and opts.mappings.enabled ~= false then
require('nuwiki.keymaps').attach(bufnr, opts.mappings)
if opts.mappings.text_objects ~= false then
require('nuwiki.textobjects').attach(bufnr)
end
end
setup_folding(bufnr, opts.folding or 'lsp')
end
return M
+78 -13
View File
@@ -1,20 +1,16 @@
-- lua/nuwiki/health.lua — `:checkhealth nuwiki` target.
--
-- Checks per SPEC §7.6: binary exists, is executable, LSP client running,
-- filetype detection works for `.wiki`.
-- v1.0 checks per SPEC §7.6 (binary, version, filetype, LSP attach).
-- v1.1 §12.10 additions: registered LSP commands, indexed wikis, HTML
-- output path writability, default keymaps installed.
local M = {}
function M.check()
local function bin_section()
local install = require('nuwiki.install')
local config = require('nuwiki.config')
vim.health.start('nuwiki')
local bin = install.expected_path()
if install.is_installed() then
vim.health.ok('binary present at ' .. bin)
local out = vim.fn.system({ bin, '--version' })
if vim.v.shell_error == 0 then
vim.health.ok('binary --version: ' .. (out:gsub('\n', ' ')):gsub('^%s+', ''))
@@ -30,8 +26,10 @@ function M.check()
{ 'Run :lua require("nuwiki").install() to install' }
)
end
end
local root = vim.fn.expand(config.options.wiki_root or '')
local function wiki_root_section(opts)
local root = vim.fn.expand(opts.wiki_root or '')
if root ~= '' then
if vim.fn.isdirectory(root) == 1 then
vim.health.ok('wiki_root exists: ' .. root)
@@ -39,21 +37,88 @@ function M.check()
vim.health.warn('wiki_root does not exist: ' .. root)
end
end
end
local function filetype_section()
if vim.fn.exists('#filetypedetect#BufRead#*.wiki') ~= 0
or vim.filetype.match({ filename = 'test.wiki' }) == 'vimwiki' then
vim.health.ok('filetype detection for *.wiki is wired up')
else
vim.health.warn('filetype detection for *.wiki not active')
end
end
local clients = vim.lsp.get_clients and vim.lsp.get_clients({ name = 'nuwiki' })
or vim.lsp.get_active_clients({ name = 'nuwiki' })
if clients and #clients > 0 then
vim.health.ok('LSP client attached (' .. #clients .. ' instance(s))')
local function clients()
return (vim.lsp.get_clients or vim.lsp.get_active_clients)({ name = 'nuwiki' })
end
local function lsp_section()
local cs = clients()
if cs and #cs > 0 then
vim.health.ok('LSP client attached (' .. #cs .. ' instance(s))')
local cl = cs[1]
local cmds = vim.tbl_get(
cl,
'server_capabilities',
'executeCommandProvider',
'commands'
) or {}
if #cmds > 0 then
vim.health.ok('server advertises ' .. #cmds .. ' executeCommand entries')
else
vim.health.info('server has not yet reported executeCommand entries')
end
local folding = vim.tbl_get(cl, 'server_capabilities', 'foldingRangeProvider')
if folding then
vim.health.ok('server supports textDocument/foldingRange')
else
vim.health.info('server has not advertised foldingRange capability')
end
else
vim.health.info('LSP client not yet attached — open a .wiki buffer')
end
end
local function html_section(opts)
local html_path = (opts.wikis and opts.wikis[1] and opts.wikis[1].html_path)
or (opts.wiki_root and (vim.fn.expand(opts.wiki_root) .. '/_html'))
if not html_path or html_path == '' then
return
end
if vim.fn.isdirectory(html_path) == 1 then
-- Probe writability with `filewritable` on the directory.
if vim.fn.filewritable(html_path) == 2 then
vim.health.ok('html output dir writable: ' .. html_path)
else
vim.health.warn('html output dir exists but not writable: ' .. html_path)
end
else
vim.health.info(
'html output dir does not exist yet (created on first export): ' .. html_path
)
end
end
local function mappings_section(opts)
if opts.mappings and opts.mappings.enabled == false then
vim.health.info('default keymaps disabled by user config')
return
end
vim.health.ok('default keymaps enabled (subgroups: '
.. table.concat(vim.tbl_keys(opts.mappings or {}), ',') .. ')')
end
function M.check()
local config = require('nuwiki.config')
local opts = config.options or config.defaults
vim.health.start('nuwiki')
bin_section()
wiki_root_section(opts)
filetype_section()
lsp_section()
html_section(opts)
mappings_section(opts)
end
return M
+73
View File
@@ -0,0 +1,73 @@
-- lua/nuwiki/keymaps.lua — buffer-local key bindings for `.wiki` buffers.
--
-- Opt-in via `mappings.enabled = true` (default). Subgroups can be
-- disabled independently — see `defaults.lua` for the schema.
--
-- Mappings here cover the subset of vimwiki's defaults whose server-side
-- commands are implemented. Anything pointing at a SPEC §13.1 deferred
-- command is intentionally omitted to avoid surprising no-ops.
local M = {}
local function map(mode, lhs, rhs, opts, bufnr)
opts = vim.tbl_extend('force', { buffer = bufnr or 0, silent = true }, opts or {})
vim.keymap.set(mode, lhs, rhs, opts)
end
--- Register all keymaps for the given buffer. Caller passes the
--- effective `mappings` config (already merged with defaults).
function M.attach(bufnr, mappings)
local cmd = require('nuwiki.commands')
if not mappings or mappings.enabled == false then
return
end
-- ===== Wiki navigation =====
map('n', '<Leader>ww', cmd.diary_today, { desc = 'nuwiki: open today\'s diary' }, bufnr)
map('n', '<Leader>wt', cmd.diary_today, { desc = 'nuwiki: open today\'s diary' }, bufnr)
map('n', '<Leader>ws', cmd.wiki_ui_select, { desc = 'nuwiki: pick wiki' }, bufnr)
map('n', '<Leader>wi', cmd.diary_index, { desc = 'nuwiki: open diary index' }, bufnr)
map('n', '<Leader>w<Leader>w', cmd.diary_today, { desc = 'nuwiki: today' }, bufnr)
map('n', '<Leader>w<Leader>y', cmd.diary_yesterday, { desc = 'nuwiki: yesterday' }, bufnr)
map('n', '<Leader>w<Leader>t', cmd.diary_tomorrow, { desc = 'nuwiki: tomorrow' }, bufnr)
map('n', '<Leader>wd', cmd.delete_file, { desc = 'nuwiki: delete page' }, bufnr)
map('n', '<Leader>wr', cmd.rename_file, { desc = 'nuwiki: rename page' }, bufnr)
map('n', '<Leader>wn', cmd.wiki_goto_page, { desc = 'nuwiki: goto page' }, bufnr)
-- Link follow / nav
map('n', '<CR>', vim.lsp.buf.definition, { desc = 'nuwiki: follow link' }, bufnr)
map('n', '<Backspace>', '<C-o>', { desc = 'nuwiki: go back', remap = true }, bufnr)
-- ===== Diary nav =====
if mappings.diary ~= false then
map('n', '<C-Down>', cmd.diary_next, { desc = 'nuwiki: next diary entry' }, bufnr)
map('n', '<C-Up>', cmd.diary_prev, { desc = 'nuwiki: previous diary entry' }, bufnr)
end
-- ===== List editing =====
if mappings.list_editing ~= false then
map('n', '<C-Space>', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr)
map('n', 'gnt', cmd.next_task, { desc = 'nuwiki: next unfinished task' }, bufnr)
map('n', 'gln', cmd.cycle_list_item, { desc = 'nuwiki: cycle checkbox' }, bufnr)
map('n', 'glp', cmd.cycle_list_item, { desc = 'nuwiki: cycle checkbox' }, bufnr)
map('n', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr)
end
-- ===== Header level =====
if mappings.header_nav ~= false then
-- `=`/`-` is the vimwiki default but collides with Vim's built-in
-- reformat operator. Use `g=` / `g-` instead to stay out of the way.
map('n', 'g=', cmd.heading_add_level, { desc = 'nuwiki: heading deeper' }, bufnr)
map('n', 'g-', cmd.heading_remove_level, { desc = 'nuwiki: heading shallower' }, bufnr)
end
-- ===== HTML export =====
if mappings.html_export ~= false then
map('n', '<Leader>wh', cmd.export_current, { desc = 'nuwiki: export to HTML' }, bufnr)
map('n', '<Leader>whh', cmd.export_browse, { desc = 'nuwiki: export + browse' }, bufnr)
map('n', '<Leader>wha', cmd.export_all, { desc = 'nuwiki: export all' }, bufnr)
end
end
return M
+64
View File
@@ -0,0 +1,64 @@
-- lua/nuwiki/textobjects.lua — text objects (Phase 19).
--
-- Currently ships `ah` / `ih` (around / inside heading). The remaining
-- four objects from SPEC §12.10 (`aH`/`iH`, `a\`/`i\`, `ac`/`ic`,
-- `al`/`il`) need the table/list rewriters from §13.1 to be fully
-- useful — they ship together with those commands in a follow-up.
local M = {}
local function heading_level(line)
local lead = line and line:match('^%s*(=+)%s')
if not lead then return 0 end
local trail = line:match('%s(=+)%s*$')
if not trail or #trail ~= #lead then return 0 end
return #lead
end
--- Find the start and end line of the heading block containing `lnum`.
--- A heading block runs from the heading line to the line before the
--- next heading of same-or-higher level (or end-of-buffer).
local function heading_block(lnum)
local total = vim.api.nvim_buf_line_count(0)
-- Walk back to the nearest heading line.
local start
for i = lnum, 1, -1 do
local lvl = heading_level(vim.fn.getline(i))
if lvl > 0 then
start = i
break
end
end
if not start then return nil end
local level = heading_level(vim.fn.getline(start))
-- Walk forward for the next heading of same-or-higher level.
local stop = total
for i = start + 1, total do
local lvl = heading_level(vim.fn.getline(i))
if lvl > 0 and lvl <= level then
stop = i - 1
break
end
end
return start, stop
end
--- Operator-pending entry point. `inner` strips the heading line itself.
function M.select(inner)
local lnum = vim.fn.line('.')
local s, e = heading_block(lnum)
if not s then return end
if inner then
s = s + 1
end
if e < s then return end
vim.cmd(string.format('normal! %dG0V%dG$', s, e))
end
function M.attach(bufnr)
local opts = { buffer = bufnr or 0, silent = true }
vim.keymap.set({ 'o', 'x' }, 'ah', function() M.select(false) end, opts)
vim.keymap.set({ 'o', 'x' }, 'ih', function() M.select(true) end, opts)
end
return M