diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index 07fb75d..f0a1ae3 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -690,13 +690,14 @@ endfunction " finishes, keeping the undo block coherent. function! nuwiki#commands#smart_return() abort let l:line = getline('.') + " `` runs under textlock, so the function must be side-effect- + " free on the buffer. The returned string is fed into Vim's input + " stream after we unwind — we encode the table-row insertion as + " explicit keystrokes (matches lua/nuwiki/commands.lua). if l:line =~# '^\s*|.*|\s*$' let l:n = max([len(split(l:line, '|', 1)) - 2, 1]) let l:new_row = '|' . repeat(' |', l:n) - let l:row = line('.') - call append(l:row, l:new_row) - call cursor(l:row + 1, 3) - return '' + return "\" . l:new_row . "\0li" endif let l:m = matchlist(l:line, '^\(\s*\)\([-*#]\)\s\(.*\)$') if empty(l:m) @@ -713,9 +714,9 @@ function! nuwiki#commands#smart_return() abort let l:has_cb = !empty(l:cb) let l:body = l:has_cb ? l:cb[2] : l:rest if l:body =~# '^\s*$' - call setline('.', '') - call cursor(line('.'), 1) - return "\" + " Empty item — drop to normal, clear the line, re-enter insert, + " newline. Indent gets dropped (matches vimwiki's break-out). + return "\0DA\" endif let l:cont = l:indent . l:marker . ' ' if l:has_cb @@ -791,4 +792,36 @@ function! nuwiki#commands#normalize_link() abort return endif call s:wrap_cword_as_wikilink() +endfunction + +" ===== Name-parity aliases ===== +" +" The Lua surface and the VimL surface diverged on a handful of names: +" +" Lua → VimL +" heading_add_level heading_add +" heading_remove_level heading_remove +" table_move_column_left table_move_left +" table_move_column_right table_move_right +" +" Real callers always go through the keymaps / `:Vimwiki*` commands +" defined in `ftplugin/vimwiki.vim`, which already route correctly. +" These aliases exist so the *public* `nuwiki#commands#*` surface +" matches Lua's `require('nuwiki.commands').*` 1:1 for any user who +" calls them by name from their own config. + +function! nuwiki#commands#heading_add_level() abort + call nuwiki#commands#heading_add() +endfunction + +function! nuwiki#commands#heading_remove_level() abort + call nuwiki#commands#heading_remove() +endfunction + +function! nuwiki#commands#table_move_column_left() abort + call nuwiki#commands#table_move_left() +endfunction + +function! nuwiki#commands#table_move_column_right() abort + call nuwiki#commands#table_move_right() endfunction \ No newline at end of file diff --git a/autoload/nuwiki/folding.vim b/autoload/nuwiki/folding.vim new file mode 100644 index 0000000..6326167 --- /dev/null +++ b/autoload/nuwiki/folding.vim @@ -0,0 +1,43 @@ +" autoload/nuwiki/folding.vim — `foldexpr` for vimwiki buffers. +" +" Mirrors lua/nuwiki/folding.lua. Pure regex over `getline()`, so works +" without a language server. Activated in `ftplugin/vimwiki.vim`: +" +" setlocal foldmethod=expr +" setlocal foldexpr=nuwiki#folding#expr() +" setlocal foldtext=nuwiki#folding#foldtext() +" +" Fold structure mirrors the document outline: each `= H1 =` opens a +" level-1 fold, each `== H2 ==` opens a level-2 fold, etc., and folds +" close implicitly at the next heading of same-or-shallower level (or +" end of buffer). + +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 + +function! nuwiki#folding#expr() abort + let l:line = getline(v:lnum) + let l:lvl = s:heading_level(l:line) + if l:lvl > 0 + return '>' . l:lvl + endif + return '=' +endfunction + +function! nuwiki#folding#foldtext() abort + let l:line = getline(v:foldstart) + let l:title = substitute(l:line, '=', '', 'g') + let l:title = substitute(l:title, '^\s\+', '', '') + let l:title = substitute(l:title, '\s\+$', '', '') + let l:count = v:foldend - v:foldstart + 1 + return '▸ ' . l:title . ' … (' . l:count . ' lines)' +endfunction diff --git a/autoload/nuwiki/textobjects.vim b/autoload/nuwiki/textobjects.vim new file mode 100644 index 0000000..543091e --- /dev/null +++ b/autoload/nuwiki/textobjects.vim @@ -0,0 +1,266 @@ +" 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 diff --git a/ftplugin/vimwiki.vim b/ftplugin/vimwiki.vim index 12b70aa..3b0f680 100644 --- a/ftplugin/vimwiki.vim +++ b/ftplugin/vimwiki.vim @@ -210,6 +210,40 @@ if !has('nvim') nnoremap gw1 :call nuwiki#commands#table_align() nnoremap :call nuwiki#commands#table_move_left() nnoremap :call nuwiki#commands#table_move_right() + + " Folding — heading-block fold structure (matches lua/nuwiki/folding.lua). + " Opt-out via `let g:nuwiki_no_folding = 1` (mirrors the Lua-side + " `folding = false` toggle on `setup()`). + if !get(g:, 'nuwiki_no_folding', 0) + setlocal foldmethod=expr + setlocal foldexpr=nuwiki#folding#expr() + setlocal foldtext=nuwiki#folding#foldtext() + let b:undo_ftplugin .= ' | setlocal foldmethod< foldexpr< foldtext<' + endif + + " Text objects — vimwiki parity (matches lua/nuwiki/textobjects.lua). + " The `:` clears any prefix count and the visual mode; the + " autoload function re-establishes the selection at the right range. + xnoremap ah :call nuwiki#textobjects#heading(0, 0) + onoremap ah :call nuwiki#textobjects#heading(0, 0) + xnoremap ih :call nuwiki#textobjects#heading(1, 0) + onoremap ih :call nuwiki#textobjects#heading(1, 0) + xnoremap aH :call nuwiki#textobjects#heading(0, 1) + onoremap aH :call nuwiki#textobjects#heading(0, 1) + xnoremap iH :call nuwiki#textobjects#heading(1, 1) + onoremap iH :call nuwiki#textobjects#heading(1, 1) + xnoremap al :call nuwiki#textobjects#list_item(0) + onoremap al :call nuwiki#textobjects#list_item(0) + xnoremap il :call nuwiki#textobjects#list_item(1) + onoremap il :call nuwiki#textobjects#list_item(1) + xnoremap a\ :call nuwiki#textobjects#table_cell(0) + onoremap a\ :call nuwiki#textobjects#table_cell(0) + xnoremap i\ :call nuwiki#textobjects#table_cell(1) + onoremap i\ :call nuwiki#textobjects#table_cell(1) + xnoremap ac :call nuwiki#textobjects#table_column(0) + onoremap ac :call nuwiki#textobjects#table_column(0) + xnoremap ic :call nuwiki#textobjects#table_column(1) + onoremap ic :call nuwiki#textobjects#table_column(1) endif finish diff --git a/scripts/test-keymaps-vim.vim b/scripts/test-keymaps-vim.vim index 01d37e4..844d1b9 100644 --- a/scripts/test-keymaps-vim.vim +++ b/scripts/test-keymaps-vim.vim @@ -162,6 +162,133 @@ call s:run('lists.O_continues_above', { \ 'expect_lines': ['- ', '- second'], \ }) +" ===== Insert-mode smart_return (Cluster 5) ===== + +call s:run('cr.continues_list_marker', { + \ 'lines': ['- first'], + \ 'cursor': [1, 7], + \ 'keys': "A\second\", + \ 'expect_lines': ['- first', '- second'], + \ }) +call s:run('cr.continues_checkbox', { + \ 'lines': ['- [ ] first'], + \ 'cursor': [1, 11], + \ 'keys': "A\second\", + \ 'expect_lines': ['- [ ] first', '- [ ] second'], + \ }) +call s:run('cr.breaks_on_empty_list_line', { + \ 'lines': ['- '], + \ 'cursor': [1, 2], + \ 'keys': "A\plain\", + \ 'expect_lines': ['', 'plain'], + \ }) +call s:run('cr.adds_new_table_row', { + \ 'lines': ['| a | b |'], + \ 'cursor': [1, 4], + \ 'keys': "A\x\", + \ 'expect_lines': ['| a | b |', '|x | |'], + \ }) + +" ===== Insert-mode smart_tab / smart_shift_tab (Cluster 6) ===== + +call s:run('tab.next_cell_in_table', { + \ 'lines': ['| a | b |'], + \ 'cursor': [1, 3], + \ 'keys': "A\x\", + \ 'expect_lines': ['| a | b |', '|x | |'], + \ }) +call s:run('tab.from_first_cell_moves_to_second', { + \ 'lines': ['| a | b |'], + \ 'cursor': [1, 3], + \ 'keys': "i\x\", + \ 'expect_lines': ['| a |x b |'], + \ }) +call s:run('shift_tab.prev_cell_in_table', { + \ 'lines': ['| a | b |'], + \ 'cursor': [1, 7], + \ 'keys': "i\x\", + \ 'expect_lines': ['|x a | b |'], + \ }) + +" ===== Named link commands (Cluster 1) ===== + +if exists(':VimwikiNextLink') == 2 + call s:record(1, 'cmd.VimwikiNextLink_defined', '') +else + call s:record(0, 'cmd.VimwikiNextLink_defined', ':VimwikiNextLink not registered') +endif +if exists(':VimwikiPrevLink') == 2 + call s:record(1, 'cmd.VimwikiPrevLink_defined', '') +else + call s:record(0, 'cmd.VimwikiPrevLink_defined', ':VimwikiPrevLink not registered') +endif +if exists(':VimwikiBaddLink') == 2 + call s:record(1, 'cmd.VimwikiBaddLink_defined', '') +else + call s:record(0, 'cmd.VimwikiBaddLink_defined', ':VimwikiBaddLink not registered') +endif + +" ===== Text objects (Cluster 3) ===== +" Exercise the pure helpers directly — visual-mode mark inspection in +" `vim -e -s` is unreliable for the same reasons the Lua harness uses +" the helper-level tests. + +call s:set_buf(['= h1 =', 'a', '== h2 ==', 'b', '== h3 ==', 'c']) +call s:record( + \ nuwiki#textobjects#_heading_block(1, 0) ==# [1, 2] + \ ? 1 : 0, + \ 'textobj.heading_block_section_only', + \ 'got ' . string(nuwiki#textobjects#_heading_block(1, 0))) +call s:record( + \ nuwiki#textobjects#_heading_block(1, 1) ==# [1, 6] + \ ? 1 : 0, + \ 'textobj.heading_block_subtree', + \ 'got ' . string(nuwiki#textobjects#_heading_block(1, 1))) + +let s:cells = nuwiki#textobjects#_cell_ranges('| a | b | c |') +call s:record( + \ len(s:cells) == 3 ? 1 : 0, + \ 'textobj.cell_ranges_three_cells', + \ len(s:cells) == 3 ? '' : 'got ' . string(s:cells)) + +let s:cell_idx = nuwiki#textobjects#_current_cell_for_line('| a | b | c |', 8) +call s:record( + \ s:cell_idx == 2 ? 1 : 0, + \ 'textobj.current_cell_picks_middle', + \ s:cell_idx == 2 ? '' : 'got idx=' . s:cell_idx) + +call setline(1, ['before', '| a | b |', '| c | d |', '', 'after']) +let s:bnd = nuwiki#textobjects#_table_bounds(2) +call s:record( + \ s:bnd ==# [2, 3] ? 1 : 0, + \ 'textobj.table_bounds_walks_contig_block', + \ s:bnd ==# [2, 3] ? '' : 'got ' . string(s:bnd)) + +" Operator-pending: `dah` should delete the heading section. +call s:set_buf(['= h1 =', 'body', '= h2 =', 'rest']) +call cursor(1, 1) +call feedkeys('dah', 'tx') +let s:dah = getline(1, '$') +call s:record( + \ s:dah ==# ['= h2 =', 'rest'] || s:dah ==# ['', '= h2 =', 'rest'] + \ ? 1 : 0, + \ 'textobj.dah_deletes_heading_section', + \ 'got ' . string(s:dah)) + +" ===== Folding ===== + +call setline(1, ['= h1 =', 'a', '== h2 ==', 'b', 'c']) +let &l:foldmethod = 'expr' +let &l:foldexpr = 'nuwiki#folding#expr()' +call s:record( + \ foldlevel(1) > 0 ? 1 : 0, + \ 'folding.heading_starts_fold', + \ 'foldlevel(1)=' . foldlevel(1)) +call s:record( + \ foldlevel(2) > 0 ? 1 : 0, + \ 'folding.body_inherits_fold', + \ 'foldlevel(2)=' . foldlevel(2)) + " ===== Wrap up ===== call add(s:results, '')