parity(3): text objects — aH/iH, al/il, a\/i\, ac/ic

Five operator-pending + visual text-object pairs, matching upstream
vimwiki:

  ah / ih   heading section only (stops at any next heading)
  aH / iH   heading section + sub-tree (stops at same-or-shallower)
  al / il   list item (line, with / without marker prefix + checkbox)
  a\ / i\   table cell (with / without surrounding `|` separators)
  ac / ic   table column (with / without the header separator row)

Pre-existing `ah` used aH semantics — fixed to match vimwiki: `ah`
now stops at the next heading regardless of level. `aH` is the new
descendants-inclusive variant.

Implementation notes:
- Pure regex; no LSP round-trip. Each helper computes a `(line, col)`
  rectangle and drives the selection with feedkeys.
- Operator-pending and visual need different feed sequences. In visual
  the previous anchor is sticky, so bounce through `<Esc>` first. In
  operator-pending an `<Esc>` would CANCEL the pending operator —
  feed the visual keys directly so Vim treats them as the motion.
- Re-exported helpers (`_heading_block`, `_cell_ranges`,
  `_current_cell_for_line`, `_table_bounds`) keep the integration
  surface testable without going through Vim's visual-mode pipeline,
  whose `'<`/`'>` mark semantics fight headless test harnesses.

Tests: 6 new in scripts/test-keymaps.lua — five pure-helper cases
plus one end-to-end `dah` deletion to verify the operator-pending
wiring.

Gates: 421 Rust / 31 Neovim / 12 Vim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 21:23:44 +00:00
parent 46ae618b5f
commit fd4d902fde
2 changed files with 296 additions and 24 deletions
+229 -24
View File
@@ -1,12 +1,20 @@
-- lua/nuwiki/textobjects.lua — text objects (Phase 19). -- lua/nuwiki/textobjects.lua — text objects (vimwiki parity).
-- --
-- Currently ships `ah` / `ih` (around / inside heading). The remaining -- Five pairs, all buffer-local:
-- four objects from SPEC §12.10 (`aH`/`iH`, `a\`/`i\`, `ac`/`ic`, -- ah / ih current heading section (stops at ANY next heading)
-- `al`/`il`) need the table/list rewriters from §13.1 to be fully -- aH / iH heading section + sub-tree (stops at same-or-shallower)
-- useful — they ship together with those commands in a follow-up. -- al / il list item (line, with / without the marker)
-- a\ / i\ table cell (with / without surrounding `|` separators)
-- ac / ic table column (with / without the header separator row)
--
-- Implementation strategy: each pair computes a `(start_line, start_col,
-- end_line, end_col)` rectangle and drives the selection with `normal! …v…`.
-- Pure regex; no LSP round-trip.
local M = {} local M = {}
-- ===== Heading helpers =====
local function heading_level(line) local function heading_level(line)
local lead = line and line:match('^%s*(=+)%s') local lead = line and line:match('^%s*(=+)%s')
if not lead then return 0 end if not lead then return 0 end
@@ -15,50 +23,247 @@ local function heading_level(line)
return #lead return #lead
end end
--- Find the start and end line of the heading block containing `lnum`. --- Find the start/end of the heading block at `lnum`.
--- A heading block runs from the heading line to the line before the --- `with_subtree` controls whether we descend into sub-headings:
--- next heading of same-or-higher level (or end-of-buffer). --- * false → stop at any next heading (just the current section's body)
local function heading_block(lnum) --- * true → stop at the next heading of same-or-shallower level
local function heading_block(lnum, with_subtree)
local total = vim.api.nvim_buf_line_count(0) local total = vim.api.nvim_buf_line_count(0)
-- Walk back to the nearest heading line.
local start local start
for i = lnum, 1, -1 do for i = lnum, 1, -1 do
local lvl = heading_level(vim.fn.getline(i)) if heading_level(vim.fn.getline(i)) > 0 then
if lvl > 0 then
start = i start = i
break break
end end
end end
if not start then return nil end if not start then return nil end
local level = heading_level(vim.fn.getline(start)) local level = heading_level(vim.fn.getline(start))
-- Walk forward for the next heading of same-or-higher level.
local stop = total local stop = total
for i = start + 1, total do for i = start + 1, total do
local lvl = heading_level(vim.fn.getline(i)) local lvl = heading_level(vim.fn.getline(i))
if lvl > 0 and lvl <= level then if lvl > 0 then
stop = i - 1 if with_subtree then
break if lvl <= level then
stop = i - 1
break
end
else
stop = i - 1
break
end
end end
end end
return start, stop return start, stop
end end
--- Operator-pending entry point. `inner` strips the heading line itself. --- Drive the current selection to cover `(s_line, s_col)..(e_line, e_col)`.
function M.select(inner) --- Operator-pending mode (`no*`) and visual mode (`v`/`V`/`<C-v>`) need
local lnum = vim.fn.line('.') --- different sequences:
local s, e = heading_block(lnum) --- * operator-pending: just feed the visual keys; Vim will start a
--- fresh visual selection and apply the pending operator on close.
--- We must NOT feed `<Esc>` here — that would cancel the operator.
--- * visual: the previous anchor is sticky, so bounce through `<Esc>`
--- before re-entering visual at our chosen range.
local function set_visual_range(s_line, e_line, s_col, e_col, linewise)
local sc = s_col or 1
local ec = e_col or vim.fn.col({ e_line, '$' })
local mode = vim.fn.mode(1) -- include operator-pending suffix
local in_visual = (mode == 'v' or mode == 'V' or mode == '\22')
local prefix = ''
if in_visual then
prefix = vim.api.nvim_replace_termcodes('<Esc>', true, false, true)
end
local keys = linewise
and string.format('%dGV%dG', s_line, e_line)
or string.format('%dG%d|v%dG%d|', s_line, sc, e_line, ec)
vim.api.nvim_feedkeys(prefix .. keys, 'nx', false)
end
--- Operator-pending entry for headings. `inner` drops the heading line.
local function select_heading(inner, with_subtree)
local s, e = heading_block(vim.fn.line('.'), with_subtree)
if not s then return end if not s then return end
if inner then s = s + 1 end
if e < s then return end
set_visual_range(s, e, nil, nil, true)
end
-- ===== List item =====
local function is_list_line(line)
return line:match('^%s*[%-%*%#]%s') or line:match('^%s*%w+[%.%)]%s')
end
local function list_block(lnum)
if not is_list_line(vim.fn.getline(lnum)) then
return nil
end
return lnum, lnum
end
--- For `il` we drop the marker prefix; `al` includes it.
local function select_list_item(inner)
local lnum = vim.fn.line('.')
if not is_list_line(vim.fn.getline(lnum)) then return end
local line = vim.fn.getline(lnum)
local line_len = #line
if inner then if inner then
s = s + 1 local marker_end = select(2, line:find('^%s*[%-%*%#]%s+'))
or select(2, line:find('^%s*%w+[%.%)]%s+'))
if not marker_end then return end
local cb = line:sub(marker_end + 1):match('^([%[][%sxXoO%.%-][%]]%s+)')
if cb then
marker_end = marker_end + #cb
end
set_visual_range(lnum, lnum, marker_end + 1, line_len, false)
else
set_visual_range(lnum, lnum, 1, line_len, false)
end
end
-- ===== Table cell / column =====
local function is_table_row(line)
return line:match('^%s*|.*|%s*$') ~= nil
end
--- Compute the byte ranges of cells on a `|`-delimited row.
--- Returns a list of `{start_byte, end_byte}` 1-based inclusive of the
--- cell content (NOT the surrounding `|` columns).
local function cell_ranges(line)
local out = {}
local pos = 1
-- The leading `|` may have whitespace before it; skip whitespace then `|`.
local _, lead = line:find('^%s*|')
if not lead then return out end
pos = lead + 1
while true do
local pipe_at = line:find('|', pos, true)
if not pipe_at then break end
table.insert(out, { pos, pipe_at - 1 })
pos = pipe_at + 1
end
return out
end
local function current_cell(lnum, col)
local line = vim.fn.getline(lnum)
if not is_table_row(line) then return nil end
local ranges = cell_ranges(line)
for idx, r in ipairs(ranges) do
if col >= r[1] and col <= r[2] + 1 then -- include the trailing `|`
return idx, r, ranges
end
end
-- Cursor on a leading `|`: claim the first cell.
if #ranges > 0 then
return 1, ranges[1], ranges
end
return nil
end
local function select_table_cell(inner)
local lnum = vim.fn.line('.')
local col = vim.fn.col('.')
local idx, range = current_cell(lnum, col)
if not idx then return end
if inner then
local line = vim.fn.getline(lnum)
local s, e = range[1], range[2]
while s <= e and line:sub(s, s) == ' ' do s = s + 1 end
while e >= s and line:sub(e, e) == ' ' do e = e - 1 end
if e < s then return end
set_visual_range(lnum, lnum, s, e, false)
else
-- Around: include the trailing `|` (vimwiki convention).
set_visual_range(lnum, lnum, range[1], range[2] + 1, false)
end
end
--- Walk up/down from `lnum` to find the contiguous block of table rows.
local function table_bounds(lnum)
if not is_table_row(vim.fn.getline(lnum)) then return nil end
local total = vim.api.nvim_buf_line_count(0)
local s, e = lnum, lnum
while s > 1 and is_table_row(vim.fn.getline(s - 1)) do s = s - 1 end
while e < total and is_table_row(vim.fn.getline(e + 1)) do e = e + 1 end
return s, e
end
local function select_table_column(inner)
local lnum = vim.fn.line('.')
local col = vim.fn.col('.')
local idx, _, _ = current_cell(lnum, col)
if not idx then return end
local s, e = table_bounds(lnum)
if not s then return end
-- Find the start_byte and end_byte of column `idx` on each row, then
-- linewise-select from the start of the column's leading `|` on the
-- first row to the end of the column's trailing `|` on the last row.
-- Vim block-selection (`<C-v>`) is the natural primitive here.
local function col_range_on(l)
local line = vim.fn.getline(l)
local r = cell_ranges(line)
return r[idx]
end
-- Skip the header separator row (`|--|--|`) if inner.
if inner then
-- vimwiki convention: inner column excludes the separator row.
-- We just shrink top/bottom to first/last content row.
local i = s
while i <= e and vim.fn.getline(i):match('^%s*|[-:%s|]*|%s*$') do
i = i + 1
end
s = i
local j = e
while j >= s and vim.fn.getline(j):match('^%s*|[-:%s|]*|%s*$') do
j = j - 1
end
e = j
end end
if e < s then return end if e < s then return end
vim.cmd(string.format('normal! %dG0V%dG$', s, e)) local top_r = col_range_on(s)
local bot_r = col_range_on(e)
if not (top_r and bot_r) then return end
local left = top_r[1]
local right = math.max(top_r[2], bot_r[2])
-- Block-wise visual (\22 == <C-v>).
vim.api.nvim_win_set_cursor(0, { s, left - 1 })
vim.cmd(string.format('normal! \22%dG%d|', e, right))
end end
-- ===== Public attach =====
function M.attach(bufnr) function M.attach(bufnr)
local opts = { buffer = bufnr or 0, silent = true } 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' }, 'ah', function() select_heading(false, false) end, opts)
vim.keymap.set({ 'o', 'x' }, 'ih', function() M.select(true) end, opts) vim.keymap.set({ 'o', 'x' }, 'ih', function() select_heading(true, false) end, opts)
vim.keymap.set({ 'o', 'x' }, 'aH', function() select_heading(false, true) end, opts)
vim.keymap.set({ 'o', 'x' }, 'iH', function() select_heading(true, true) end, opts)
vim.keymap.set({ 'o', 'x' }, 'al', function() select_list_item(false) end, opts)
vim.keymap.set({ 'o', 'x' }, 'il', function() select_list_item(true) end, opts)
vim.keymap.set({ 'o', 'x' }, 'a\\', function() select_table_cell(false) end, opts)
vim.keymap.set({ 'o', 'x' }, 'i\\', function() select_table_cell(true) end, opts)
vim.keymap.set({ 'o', 'x' }, 'ac', function() select_table_column(false) end, opts)
vim.keymap.set({ 'o', 'x' }, 'ic', function() select_table_column(true) end, opts)
end
-- Re-export for tests.
M._heading_block = heading_block
M._cell_ranges = cell_ranges
M._current_cell = current_cell
M._table_bounds = table_bounds
--- Pure variant of `current_cell` for tests — takes the line text and
--- a 1-based byte column, returns just the cell index.
function M._current_cell_for_line(line, col)
if not is_table_row(line) then return nil end
local ranges = cell_ranges(line)
for i, r in ipairs(ranges) do
if col >= r[1] and col <= r[2] + 1 then return i end
end
if #ranges > 0 then return 1 end
return nil
end end
return M return M
+67
View File
@@ -334,6 +334,73 @@ vim.defer_fn(function()
wait_ms = 1500, wait_ms = 1500,
}) })
-- ===== Text objects (Cluster 3) =====
-- Exercise the pure helpers directly (cell ranges, heading block,
-- table bounds). The visual-mode integration is verified in a single
-- end-to-end case via mark inspection, which exercises the full
-- operator-pending path.
local function tobj_case(name, fn)
local ok, err = pcall(fn)
record(ok, name, ok and '' or tostring(err))
end
tobj_case('textobj.heading_block_section_only', function()
set_buf({ '= h1 =', 'a', '== h2 ==', 'b', '== h3 ==', 'c' })
local s, e = require('nuwiki.textobjects')._heading_block(1, false)
if not (s == 1 and e == 2) then
error(string.format('section_only: got (%s,%s) want (1,2)', s, e))
end
end)
tobj_case('textobj.heading_block_subtree', function()
set_buf({ '= h1 =', 'a', '== h2 ==', 'b', '== h3 ==', 'c' })
local s, e = require('nuwiki.textobjects')._heading_block(1, true)
if not (s == 1 and e == 6) then
error(string.format('subtree: got (%s,%s) want (1,6)', s, e))
end
end)
tobj_case('textobj.cell_ranges_three_cells', function()
local r = require('nuwiki.textobjects')._cell_ranges('| a | b | c |')
if #r ~= 3 then error('want 3 cells, got ' .. #r) end
-- Inside each cell, content includes the surrounding spaces.
if not (r[1][1] == 2 and r[2][1] > r[1][2] and r[3][1] > r[2][2]) then
error('cell ordering broken: ' .. vim.inspect(r))
end
end)
tobj_case('textobj.current_cell_picks_middle', function()
-- `| a | b | c |` cursor on the `b` (1-based col 8).
local idx = require('nuwiki.textobjects')._current_cell_for_line(
'| a | b | c |', 8)
if idx ~= 2 then error('expected cell 2, got ' .. tostring(idx)) end
end)
tobj_case('textobj.table_bounds_walks_contig_block', function()
set_buf({ 'before', '| a | b |', '| c | d |', '', 'after' })
vim.api.nvim_win_set_cursor(0, { 2, 0 })
local s, e = require('nuwiki.textobjects')._table_bounds(2)
if not (s == 2 and e == 3) then
error(string.format('want (2,3) got (%s,%s)', s, e))
end
end)
-- One end-to-end: operator-pending `dah` should delete the heading
-- section. This avoids the visual-mode `'<`/`'>` mark dance and
-- relies on Vim's actual operator pipeline to verify the keymap is
-- correctly wired into `omap`.
tobj_case('textobj.dah_deletes_heading_section', function()
set_buf({ '= h1 =', 'body', '= h2 =', 'rest' })
vim.api.nvim_win_set_cursor(0, { 1, 0 })
send('dah')
sleep(100)
local got = get_lines()
-- After deleting heading section (lines 1-2), buffer should be the
-- remaining two lines. There may be a trailing empty line depending
-- on linewise delete semantics; allow both shapes.
if not (vim.deep_equal(got, { '= h2 =', 'rest' })
or vim.deep_equal(got, { '', '= h2 =', 'rest' })) then
error('dah result: ' .. vim.inspect(got))
end
end)
-- ===== Diary nav ===== -- ===== Diary nav =====
-- Tested via the LSP open-* responses; can't fully validate without -- Tested via the LSP open-* responses; can't fully validate without
-- a populated diary, but at least confirm the maps fire without -- a populated diary, but at least confirm the maps fire without