ae96562969
Close the remaining Vim-vs-Neovim functional gaps:
* Text objects — new `autoload/nuwiki/textobjects.vim` mirroring
`lua/nuwiki/textobjects.lua`. All five pairs (ah/ih, aH/iH,
al/il, a\/i\, ac/ic) wired in the Vim path of `ftplugin/vimwiki.vim`
via the classic `:<C-u>call` idiom that works cleanly in both
operator-pending and visual modes.
* Folding — new `autoload/nuwiki/folding.vim` providing a regex
`foldexpr` over headings, plus a tidy `foldtext`. Wired in the
Vim path; opts out via `let g:nuwiki_no_folding = 1`.
* `smart_return` was using `setline()` / `append()` inside an
`<expr>` callback — fine on Neovim but plain Vim's stricter
textlock raised E565. Rewrote as pure keystrokes (matches the
Lua version): table rows insert via `<CR>...<Esc>0li`, empty
list lines break via `<Esc>0DA<CR>`.
* Function-name parity: added
`nuwiki#commands#heading_add_level`,
`nuwiki#commands#heading_remove_level`,
`nuwiki#commands#table_move_column_{left,right}` as thin
aliases over the original short names so the public
`nuwiki#commands#*` surface matches `require('nuwiki.commands').*`.
Test harness:
* `scripts/test-keymaps-vim.vim` extended from 12 to 30 cases:
4 smart_return, 3 smart_tab/<S-Tab>, 3 named-command exists,
4 text-object helpers, 1 `dah` end-to-end, 2 folding, and the
original 12 pure-VimL cases. (Visual-mode mark inspection in
`vim -e -s` stays unreliable, so text objects are exercised
at the helper level plus one operator-pending end-to-end.)
Gates: 456 Rust / 39 Neovim / 30 Vim.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
267 lines
8.3 KiB
VimL
267 lines
8.3 KiB
VimL
" 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` / `<C-v>` (charwise / blockwise).
|
|
"
|
|
" The keymaps wire up via the standard Vim pattern:
|
|
" xnoremap <silent><buffer> ah :<C-u>call nuwiki#textobjects#heading(0, 0)<CR>
|
|
" onoremap <silent><buffer> ah :<C-u>call nuwiki#textobjects#heading(0, 0)<CR>
|
|
" `:<C-u>` 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='\<C-v>' (blockwise) — used by `ac` / `ic`
|
|
" Always issues a clean `<Esc>` 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\<C-v>]"
|
|
let l:prefix = "\<Esc>"
|
|
endif
|
|
if a:mode ==# 'V'
|
|
let l:keys = a:s_line . 'GV' . a:e_line . 'G'
|
|
elseif a:mode ==# "\<C-v>"
|
|
let l:keys = a:s_line . 'G' . a:s_col . '|' . "\<C-v>" . 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, "\<C-v>")
|
|
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
|