parity(5): smart <CR> in insert mode (VimwikiReturn)

Bind `<CR>` in insert mode as an `<expr>` map to a `smart_return`
helper. Behaviour matches upstream vimwiki's `:VimwikiReturn`:

  - On a list line with content → continue the list with the same
    marker on a new line (preserving leading checkbox `[ ]` if any).
  - On an empty list line (marker only) → clear the marker and break
    out of the list with a plain newline.
  - Inside a `|…|` table row → insert a fresh empty row below with
    the same column count, cursor lands inside the first cell.
  - Otherwise → plain `<CR>`.

Implementation note: the helper is `<expr>`-bound, which means
textlock is active and the buffer can't be mutated from inside the
callback. The function returns key sequences only — including
`<Esc>0DA<CR>` for the empty-list break and `<Esc>0li` for the
table-row cursor jump — so every effect flows through Vim's normal
keystroke pipeline and stays undo-coherent.

Both Lua and VimL counterparts shipped.

Tests: 4 new in scripts/test-keymaps.lua covering list continuation,
checkbox preservation, empty-marker break-out, and new table row.

Gates: 421 Rust / 35 Neovim / 12 Vim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 21:34:36 +00:00
parent fd4d902fde
commit e347f605ff
5 changed files with 125 additions and 0 deletions
+42
View File
@@ -684,6 +684,48 @@ function! nuwiki#commands#paste_url() abort
call s:exec('nuwiki.link.pasteUrl', l:args)
endfunction
" Smart <CR> for insert mode — vimwiki `:VimwikiReturn` parity.
" Used as `inoremap <expr> <CR> nuwiki#commands#smart_return()`. The
" returned string is fed into Vim's input stream after the function
" finishes, keeping the undo block coherent.
function! nuwiki#commands#smart_return() abort
let l:line = getline('.')
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 ''
endif
let l:m = matchlist(l:line, '^\(\s*\)\([-*#]\)\s\(.*\)$')
if empty(l:m)
let l:m2 = matchlist(l:line, '^\(\s*\)\(\d\+\)\([.)]\)\s\(.*\)$')
if !empty(l:m2)
let l:m = [l:line, l:m2[1], l:m2[2] . l:m2[3], l:m2[4]]
endif
endif
if !empty(l:m)
let l:indent = l:m[1]
let l:marker = l:m[2]
let l:rest = l:m[3]
let l:cb = matchlist(l:rest, '^\(\[[ xXoO.\-]\]\)\s*\(.*\)$')
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 "\<CR>"
endif
let l:cont = l:indent . l:marker . ' '
if l:has_cb
let l:cont .= '[ ] '
endif
return "\<CR>" . l:cont
endif
return "\<CR>"
endfunction
" `:VimwikiNormalizeLink` — wrap the word at cursor as `[[word]]`
" without following. Pure-VimL.
function! nuwiki#commands#normalize_link() abort
+1
View File
@@ -175,6 +175,7 @@ if !has('nvim')
inoremap <silent><buffer> <C-L><C-J> <C-o>:call nuwiki#commands#list_cycle_symbol(1)<CR>
inoremap <silent><buffer> <C-L><C-K> <C-o>:call nuwiki#commands#list_cycle_symbol(-1)<CR>
inoremap <silent><buffer> <C-L><C-M> <C-o>:call nuwiki#commands#list_toggle_or_add_checkbox()<CR>
inoremap <silent><buffer><expr> <CR> nuwiki#commands#smart_return()
" Headers
nnoremap <silent><buffer> = :call nuwiki#commands#heading_add()<CR>
+45
View File
@@ -582,6 +582,51 @@ function M.list_toggle_or_add_checkbox()
vim.api.nvim_buf_set_lines(0, row - 1, row, false, { new_line })
end
--- Smart `<CR>` for insert mode — vimwiki parity for `:VimwikiReturn`.
--- Used as an `<expr>` keymap so the function is side-effect-free on
--- the buffer (textlock is active). The returned string is fed into
--- Vim's input stream — we encode everything (including the table-row
--- insertion) as keystrokes so the user's typed keys after `<CR>` land
--- at the right cursor position.
function M.smart_return()
local line = vim.api.nvim_get_current_line()
local function tc(s) return vim.api.nvim_replace_termcodes(s, true, false, true) end
local cr = tc('<CR>')
local esc = tc('<Esc>')
-- Table row → newline + empty row + cursor inside first cell.
if line:match('^%s*|.*|%s*$') then
local cell_count = 0
for _ in line:gmatch('|') do cell_count = cell_count + 1 end
cell_count = math.max(cell_count - 1, 1)
local new_row = '|' .. string.rep(' |', cell_count)
-- After `<CR>` + new_row, cursor sits at end of new row. Bounce to
-- normal, jump to col 0, step right past the leading `|`, re-enter
-- insert: `<Esc>0li` puts the cursor immediately after the `|`.
return cr .. new_row .. esc .. '0li'
end
-- List item with marker?
local indent, marker, after = line:match('^(%s*)([%-%*%#])%s(.*)$')
if not marker then
local n
indent, n, marker, after = line:match('^(%s*)(%d+)([%.%)])%s(.*)$')
if marker then marker = n .. marker end
end
if marker then
local cb = after:match('^%[[%sxXoO%.%-]%]%s*(.*)$')
local body = cb or after
if body:match('^%s*$') then
-- Empty item → drop to normal, clear the line, re-enter insert,
-- then a fresh newline. Indent is dropped (matches vimwiki's
-- "break out of the list" behaviour on an empty bullet).
return esc .. '0DA' .. cr
end
return cr .. indent .. marker .. ' ' .. (cb and '[ ] ' or '')
end
return cr
end
-- §13.1 Cluster B — table rewriters.
function M.table_insert(cols, rows)
+5
View File
@@ -254,6 +254,11 @@ function M.attach(bufnr, mappings)
{ desc = 'nuwiki: cycle list symbol backward (insert)' }, bufnr)
map('i', '<C-L><C-M>', cmd.list_toggle_or_add_checkbox,
{ desc = 'nuwiki: toggle/add checkbox (insert)' }, bufnr)
-- Smart <CR>: continue list / table rows. `<expr>` so the function
-- result is the keystrokes fed — keeps undo coherent.
map('i', '<CR>', cmd.smart_return,
{ desc = 'nuwiki: smart return (insert)', expr = true }, bufnr)
end
-- ===== Header levels + nav =====
+32
View File
@@ -401,6 +401,38 @@ vim.defer_fn(function()
end
end)
-- ===== Smart <CR> (Cluster 5) =====
run('cr.continues_list_marker', {
lines = { '- first' },
cursor = { 1, 7 }, -- end of line
keys = 'A<CR>second<Esc>',
expect_lines = { '- first', '- second' },
wait_ms = 100,
})
run('cr.continues_checkbox', {
lines = { '- [ ] first' },
cursor = { 1, 11 },
keys = 'A<CR>second<Esc>',
expect_lines = { '- [ ] first', '- [ ] second' },
wait_ms = 100,
})
run('cr.breaks_on_empty_list_line', {
lines = { '- ' },
cursor = { 1, 2 },
keys = 'A<CR>plain<Esc>',
expect_lines = { '', 'plain' },
wait_ms = 100,
})
run('cr.adds_new_table_row', {
lines = { '| a | b |' },
cursor = { 1, 4 }, -- inside `a`
keys = 'A<CR>x<Esc>',
-- New row inserted below with empty cells; cursor jumps to first.
expect_lines = { '| a | b |', '|x | |' },
wait_ms = 100,
})
-- ===== Diary nav =====
-- Tested via the LSP open-* responses; can't fully validate without
-- a populated diary, but at least confirm the maps fire without