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:
+229
-24
@@ -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
|
||||
-- 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.
|
||||
-- Five pairs, all buffer-local:
|
||||
-- ah / ih current heading section (stops at ANY next heading)
|
||||
-- aH / iH heading section + sub-tree (stops at same-or-shallower)
|
||||
-- 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 = {}
|
||||
|
||||
-- ===== Heading helpers =====
|
||||
|
||||
local function heading_level(line)
|
||||
local lead = line and line:match('^%s*(=+)%s')
|
||||
if not lead then return 0 end
|
||||
@@ -15,50 +23,247 @@ local function heading_level(line)
|
||||
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)
|
||||
--- Find the start/end of the heading block at `lnum`.
|
||||
--- `with_subtree` controls whether we descend into sub-headings:
|
||||
--- * false → stop at any next heading (just the current section's body)
|
||||
--- * 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)
|
||||
-- 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
|
||||
if heading_level(vim.fn.getline(i)) > 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
|
||||
if lvl > 0 then
|
||||
if with_subtree then
|
||||
if lvl <= level then
|
||||
stop = i - 1
|
||||
break
|
||||
end
|
||||
else
|
||||
stop = i - 1
|
||||
break
|
||||
end
|
||||
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)
|
||||
--- Drive the current selection to cover `(s_line, s_col)..(e_line, e_col)`.
|
||||
--- Operator-pending mode (`no*`) and visual mode (`v`/`V`/`<C-v>`) need
|
||||
--- different sequences:
|
||||
--- * 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 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
|
||||
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
|
||||
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
|
||||
|
||||
-- ===== Public attach =====
|
||||
|
||||
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)
|
||||
vim.keymap.set({ 'o', 'x' }, 'ah', function() select_heading(false, false) 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
|
||||
|
||||
return M
|
||||
|
||||
Reference in New Issue
Block a user