From fd4d902fdec7508b7f0f2a305c2b75739e23ac00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Tue, 12 May 2026 21:23:44 +0000 Subject: [PATCH] =?UTF-8?q?parity(3):=20text=20objects=20=E2=80=94=20aH/iH?= =?UTF-8?q?,=20al/il,=20a\/i\,=20ac/ic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` first. In operator-pending an `` 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) --- lua/nuwiki/textobjects.lua | 253 +++++++++++++++++++++++++++++++++---- scripts/test-keymaps.lua | 67 ++++++++++ 2 files changed, 296 insertions(+), 24 deletions(-) diff --git a/lua/nuwiki/textobjects.lua b/lua/nuwiki/textobjects.lua index 3fa7150..f687094 100644 --- a/lua/nuwiki/textobjects.lua +++ b/lua/nuwiki/textobjects.lua @@ -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`/``) 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 `` here — that would cancel the operator. +--- * visual: the previous anchor is sticky, so bounce through `` +--- 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('', 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 (``) 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 == ). + 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 diff --git a/scripts/test-keymaps.lua b/scripts/test-keymaps.lua index cafa0d1..4db752b 100644 --- a/scripts/test-keymaps.lua +++ b/scripts/test-keymaps.lua @@ -334,6 +334,73 @@ vim.defer_fn(function() 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 ===== -- Tested via the LSP open-* responses; can't fully validate without -- a populated diary, but at least confirm the maps fire without