fd4d902fde
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>
270 lines
9.1 KiB
Lua
270 lines
9.1 KiB
Lua
-- lua/nuwiki/textobjects.lua — text objects (vimwiki parity).
|
|
--
|
|
-- 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
|
|
local trail = line:match('%s(=+)%s*$')
|
|
if not trail or #trail ~= #lead then return 0 end
|
|
return #lead
|
|
end
|
|
|
|
--- 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)
|
|
local start
|
|
for i = lnum, 1, -1 do
|
|
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))
|
|
local stop = total
|
|
for i = start + 1, total do
|
|
local lvl = heading_level(vim.fn.getline(i))
|
|
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
|
|
|
|
--- 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
|
|
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
|
|
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() 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
|