" autoload/nuwiki/textobjects.vim — vimwiki parity text objects for plain Vim. " " Mirrors lua/nuwiki/textobjects.lua. Five operator-pending + visual pairs: " " 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) " " Pure regex / `getline()` — no LSP round-trip. Each public function " computes the (start_line, start_col, end_line, end_col) rectangle and " hands it to `s:select_range` which issues a single `normal!` command " with either `V` (linewise) or `v` / `` (charwise / blockwise). " " The keymaps wire up via the standard Vim pattern: " xnoremap ah :call nuwiki#textobjects#heading(0, 0) " onoremap ah :call nuwiki#textobjects#heading(0, 0) " `:` clears the prefix count (and exits visual cleanly for xmap); " the function then re-establishes the visual selection. From " operator-pending mode Vim absorbs the resulting visual range as the " pending operator's motion. " ===== Heading helpers ===== function! s:heading_level(line) abort let l:lead = matchstr(a:line, '^\s*\zs=\+\ze\s') if empty(l:lead) | return 0 | endif let l:lvl = strlen(l:lead) if l:lvl > 6 | return 0 | endif let l:trail = matchstr(a:line, '\s\zs=\+\ze\s*$') if empty(l:trail) || strlen(l:trail) != l:lvl return 0 endif return l:lvl endfunction " Returns `[start, end]` line numbers, or `[]` if no heading found. function! s:heading_block(lnum, with_subtree) abort let l:total = line('$') let l:start = 0 let l:i = a:lnum while l:i >= 1 if s:heading_level(getline(l:i)) > 0 let l:start = l:i break endif let l:i -= 1 endwhile if l:start == 0 | return [] | endif let l:level = s:heading_level(getline(l:start)) let l:stop = l:total let l:j = l:start + 1 while l:j <= l:total let l:lvl = s:heading_level(getline(l:j)) if l:lvl > 0 if a:with_subtree if l:lvl <= l:level let l:stop = l:j - 1 break endif else let l:stop = l:j - 1 break endif endif let l:j += 1 endwhile return [l:start, l:stop] endfunction " Drive a fresh visual selection covering the given range. " - mode='V' (linewise) — only s_line / e_line matter " - mode='v' (charwise) — s_col / e_col are 1-based " - mode='\' (blockwise) — used by `ac` / `ic` " Always issues a clean `` first so any in-progress visual anchor " doesn't stick. function! s:select_range(s_line, s_col, e_line, e_col, mode) abort let l:cur_mode = mode(1) let l:prefix = '' if l:cur_mode =~# "^[vV\]" let l:prefix = "\" endif if a:mode ==# 'V' let l:keys = a:s_line . 'GV' . a:e_line . 'G' elseif a:mode ==# "\" let l:keys = a:s_line . 'G' . a:s_col . '|' . "\" . a:e_line . 'G' . a:e_col . '|' else let l:keys = a:s_line . 'G' . a:s_col . '|v' . a:e_line . 'G' . a:e_col . '|' endif call feedkeys(l:prefix . l:keys, 'nx') endfunction function! nuwiki#textobjects#heading(inner, with_subtree) abort let l:r = s:heading_block(line('.'), a:with_subtree) if empty(l:r) | return | endif let [l:s, l:e] = l:r if a:inner | let l:s += 1 | endif if l:e < l:s | return | endif call s:select_range(l:s, 0, l:e, 0, 'V') endfunction " ===== List item ===== function! s:is_list_line(line) abort return a:line =~# '^\s*[-*#]\s' || a:line =~# '^\s*\w\+[.)]\s' endfunction function! nuwiki#textobjects#list_item(inner) abort let l:lnum = line('.') let l:line = getline(l:lnum) if !s:is_list_line(l:line) | return | endif let l:line_len = strlen(l:line) if a:inner let l:m = matchend(l:line, '^\s*[-*#]\s\+') if l:m < 0 let l:m = matchend(l:line, '^\s*\w\+[.)]\s\+') endif if l:m < 0 | return | endif let l:cb_len = matchend(l:line[l:m :], '^\[[ xXoO.\-]\]\s\+') if l:cb_len > 0 let l:m += l:cb_len endif call s:select_range(l:lnum, l:m + 1, l:lnum, l:line_len, 'v') else call s:select_range(l:lnum, 1, l:lnum, l:line_len, 'v') endif endfunction " ===== Table cell / column ===== function! s:is_table_row(line) abort return a:line =~# '^\s*|.*|\s*$' endfunction " Returns a list of `[start_col, end_col]` byte positions (1-based, " inclusive) for the *content* of each cell on the row — i.e. the " character span between two `|` separators, not including the " separators themselves. function! s:cell_ranges(line) abort let l:out = [] let l:lead = matchend(a:line, '^\s*|') if l:lead < 0 | return l:out | endif let l:pos = l:lead + 1 while 1 let l:next = stridx(a:line, '|', l:pos - 1) if l:next < 0 | break | endif " 1-based, inclusive: content runs from l:pos (after prior |) to l:next. call add(l:out, [l:pos, l:next]) let l:pos = l:next + 2 endwhile return l:out endfunction " Find which cell index (1-based) the cursor is in. Returns `[idx, range]` " or `[0, []]` if not on a table row. function! s:current_cell(lnum, col) abort let l:line = getline(a:lnum) if !s:is_table_row(l:line) | return [0, []] | endif let l:ranges = s:cell_ranges(l:line) let l:i = 0 for l:r in l:ranges let l:i += 1 if a:col >= l:r[0] && a:col <= l:r[1] + 1 return [l:i, l:r] endif endfor if !empty(l:ranges) return [1, l:ranges[0]] endif return [0, []] endfunction function! nuwiki#textobjects#table_cell(inner) abort let l:lnum = line('.') let l:col = col('.') let [l:idx, l:range] = s:current_cell(l:lnum, l:col) if l:idx == 0 | return | endif if a:inner let l:line = getline(l:lnum) let l:s = l:range[0] let l:e = l:range[1] while l:s <= l:e && l:line[l:s - 1] ==# ' ' let l:s += 1 endwhile while l:e >= l:s && l:line[l:e - 1] ==# ' ' let l:e -= 1 endwhile if l:e < l:s | return | endif call s:select_range(l:lnum, l:s, l:lnum, l:e, 'v') else call s:select_range(l:lnum, l:range[0], l:lnum, l:range[1] + 1, 'v') endif endfunction " Walk up/down from `lnum` to find the contiguous block of table rows. function! s:table_bounds(lnum) abort if !s:is_table_row(getline(a:lnum)) | return [] | endif let l:total = line('$') let l:s = a:lnum let l:e = a:lnum while l:s > 1 && s:is_table_row(getline(l:s - 1)) let l:s -= 1 endwhile while l:e < l:total && s:is_table_row(getline(l:e + 1)) let l:e += 1 endwhile return [l:s, l:e] endfunction function! nuwiki#textobjects#table_column(inner) abort let l:lnum = line('.') let l:col = col('.') let [l:idx, l:_r] = s:current_cell(l:lnum, l:col) if l:idx == 0 | return | endif let l:bnd = s:table_bounds(l:lnum) if empty(l:bnd) | return | endif let [l:s, l:e] = l:bnd if a:inner " Drop a header-separator row (`|---|---|`) from the inner column. while l:s <= l:e && getline(l:s) =~# '^\s*|[-:|\s]*|\s*$' let l:s += 1 endwhile while l:e >= l:s && getline(l:e) =~# '^\s*|[-:|\s]*|\s*$' let l:e -= 1 endwhile endif if l:e < l:s | return | endif let l:top = s:cell_ranges(getline(l:s)) let l:bot = s:cell_ranges(getline(l:e)) if l:idx > len(l:top) || l:idx > len(l:bot) | return | endif let l:left = l:top[l:idx - 1][0] let l:right = max([l:top[l:idx - 1][1], l:bot[l:idx - 1][1]]) call s:select_range(l:s, l:left, l:e, l:right, "\") endfunction " ===== Debug exports (test harness) ===== " " Mirror the `M._*` re-exports the Lua side has. Test code calls these " so failures land at the calculation, not the visual-mode pipeline. function! nuwiki#textobjects#_heading_block(lnum, with_subtree) abort return s:heading_block(a:lnum, a:with_subtree) endfunction function! nuwiki#textobjects#_cell_ranges(line) abort return s:cell_ranges(a:line) endfunction function! nuwiki#textobjects#_current_cell_for_line(line, col) abort if !s:is_table_row(a:line) | return 0 | endif let l:ranges = s:cell_ranges(a:line) let l:i = 0 for l:r in l:ranges let l:i += 1 if a:col >= l:r[0] && a:col <= l:r[1] + 1 return l:i endif endfor if !empty(l:ranges) | return 1 | endif return 0 endfunction function! nuwiki#textobjects#_table_bounds(lnum) abort return s:table_bounds(a:lnum) endfunction