From 5b6f789c8db9d9bf327b96f16467045d05d9d925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Wed, 13 May 2026 01:10:05 +0000 Subject: [PATCH] fix(tables): auto-realign on edit + stop dragging trailing | onto new row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the insert-mode table editing path: 1. past the last cell (and on a table row) used +typed keystrokes to create a new row, which split the current line at the cursor — pulling the trailing | down onto the next line and mangling the formatting. Both paths now go through helpers that append() a fresh row beside the current one without touching it. 2. The table never realigned to new content. Ported the LSP-side render_aligned_table algorithm to Lua + VimL so smart_tab, smart_shift_tab, and smart_return tighten column widths locally on every navigation. No vim-lsp / nuwiki server roundtrip required. Neovim side schedules the work via vim.schedule (textlock-safe); plain Vim hands off via :call … to keep insert mode and avoid the cmdline-mode flash. Co-Authored-By: Claude Opus 4.7 (1M context) --- autoload/nuwiki/commands.vim | 244 +++++++++++++++++++++++++------ lua/nuwiki/commands.lua | 272 ++++++++++++++++++++++++++++------- 2 files changed, 424 insertions(+), 92 deletions(-) diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index f0a1ae3..21e2ee6 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -684,6 +684,170 @@ function! nuwiki#commands#paste_url() abort call s:exec('nuwiki.link.pasteUrl', l:args) endfunction +" ===== Pure-VimL table alignment ===== +" +" Mirrors `lua/nuwiki/commands.lua`'s `align_table_at` so plain Vim +" buffers get the same "tighten columns as cells grow" behaviour the +" Neovim path enjoys, without needing a vim-lsp roundtrip. Algorithm +" matches `crates/nuwiki-lsp/src/commands.rs::ops::render_aligned_table`. + +function! s:is_table_row(line) abort + return a:line =~# '^\s*|.*|\s*$' +endfunction + +function! s:table_cell_starts(line) abort + " 1-based byte positions of each `|`. Cursor lands one past pipe N at + " 1-based byte (starts[N] + 1) — that's the first space inside the + " cell the pipe opens. + let l:out = [] + let l:pos = 0 + while 1 + let l:bar = stridx(a:line, '|', l:pos) + if l:bar < 0 | break | endif + call add(l:out, l:bar + 1) + let l:pos = l:bar + 1 + endwhile + return l:out +endfunction + +function! s:find_table_range(row) abort + let l:total = line('$') + let l:s = a:row + while l:s > 1 && s:is_table_row(getline(l:s - 1)) + let l:s -= 1 + endwhile + let l:e = a:row + 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! s:parse_table_row(line) abort + let l:indent = matchstr(a:line, '^\s*') + let l:body = strpart(a:line, len(l:indent)) + let l:segments = [] + let l:pos = 0 + while 1 + let l:bar = stridx(l:body, '|', l:pos) + if l:bar < 0 + call add(l:segments, strpart(l:body, l:pos)) + break + endif + call add(l:segments, strpart(l:body, l:pos, l:bar - l:pos)) + let l:pos = l:bar + 1 + endwhile + " Drop the empty piece before the opening `|` and the trailing piece + " after the closing `|`. + if len(l:segments) > 0 && l:segments[0] ==# '' + call remove(l:segments, 0) + endif + if len(l:segments) > 0 && l:segments[-1] =~# '^\s*$' + call remove(l:segments, -1) + endif + call map(l:segments, {_, c -> substitute(substitute(c, '^\s\+', '', ''), '\s\+$', '', '')}) + return [l:indent, l:segments] +endfunction + +function! s:is_sep_cell(s) abort + return a:s !=# '' && a:s =~# '^[-:[:space:]]\+$' +endfunction + +function! s:is_sep_row(cells) abort + if empty(a:cells) | return 0 | endif + for l:c in a:cells + if !s:is_sep_cell(l:c) | return 0 | endif + endfor + return 1 +endfunction + +function! s:compute_widths(rows) abort + let l:max_cols = 0 + for l:r in a:rows + if len(l:r.cells) > l:max_cols + let l:max_cols = len(l:r.cells) + endif + endfor + let l:widths = repeat([1], l:max_cols) + for l:r in a:rows + if !s:is_sep_row(l:r.cells) + let l:i = 0 + for l:c in l:r.cells + let l:w = strchars(l:c) + if l:w > l:widths[l:i] + let l:widths[l:i] = l:w + endif + let l:i += 1 + endfor + endif + endfor + return l:widths +endfunction + +function! s:render_table_row(indent, cells, widths, all_sep) abort + let l:out = a:indent . '|' + let l:i = 0 + for l:w in a:widths + if a:all_sep + let l:out .= repeat('-', l:w + 2) + else + let l:c = l:i < len(a:cells) ? a:cells[l:i] : '' + let l:pad = l:w - strchars(l:c) + if l:pad < 0 | let l:pad = 0 | endif + let l:out .= ' ' . l:c . repeat(' ', l:pad) . ' ' + endif + let l:out .= '|' + let l:i += 1 + endfor + return l:out +endfunction + +function! s:align_table_at(row) abort + let [l:s, l:e] = s:find_table_range(a:row) + let l:rows = [] + let l:i = l:s + while l:i <= l:e + let [l:indent, l:cells] = s:parse_table_row(getline(l:i)) + call add(l:rows, { 'indent': l:indent, 'cells': l:cells }) + let l:i += 1 + endwhile + let l:widths = s:compute_widths(l:rows) + if empty(l:widths) | return | endif + let l:new_lines = [] + for l:r in l:rows + call add(l:new_lines, s:render_table_row(l:r.indent, l:r.cells, l:widths, s:is_sep_row(l:r.cells))) + endfor + call setline(l:s, l:new_lines) +endfunction + +" Public helpers invoked via `` from the `` mappings. We +" can't mutate the buffer from inside the `` body (textlock), so +" the mappings hand off here via `:call …`, which runs an Ex +" command without leaving insert mode. + +function! nuwiki#commands#_table_insert_row_below(row, cursor_col) abort + call s:align_table_at(a:row) + let l:line = getline(a:row) + let l:indent = matchstr(l:line, '^\s*') + let l:starts = s:table_cell_starts(l:line) + let l:cells = max([len(l:starts) - 1, 1]) + let l:new_row = l:indent . '|' . repeat(' |', l:cells) + call append(a:row, l:new_row) + call cursor(a:row + 1, a:cursor_col) +endfunction + +function! nuwiki#commands#_table_jump_to_cell(row, target_idx) abort + call s:align_table_at(a:row) + let l:starts = s:table_cell_starts(getline(a:row)) + if a:target_idx < 1 || a:target_idx >= len(l:starts) + return + endif + " `starts[N]` is the 1-based byte position of the N-th `|`. The first + " space inside the cell that pipe opens sits at byte `starts[N] + 1` + " (1-based), which is what `cursor()` wants. + call cursor(a:row, l:starts[a:target_idx - 1] + 1) +endfunction + " Smart for insert mode — vimwiki `:VimwikiReturn` parity. " Used as `inoremap nuwiki#commands#smart_return()`. The " returned string is fed into Vim's input stream after the function @@ -691,13 +855,12 @@ endfunction 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). + " free on the buffer. Buffer-mutating branches return a `:call + " …` keystroke that hands off to a regular function. if l:line =~# '^\s*|.*|\s*$' - let l:n = max([len(split(l:line, '|', 1)) - 2, 1]) - let l:new_row = '|' . repeat(' |', l:n) - return "\" . l:new_row . "\0li" + let l:indent = matchstr(l:line, '^\s*') + let l:cursor_col = len(l:indent) + 2 + return "\call nuwiki#commands#_table_insert_row_below(" . line('.') . ", " . l:cursor_col . ")\" endif let l:m = matchlist(l:line, '^\(\s*\)\([-*#]\)\s\(.*\)$') if empty(l:m) @@ -730,21 +893,10 @@ endfunction " Insert-mode `` / `` table navigation — vimwiki parity. " Used as `` mappings so we can pass `` / `` through " verbatim when the cursor isn't on a table row. -function! s:table_cell_starts(line) abort - let l:out = [] - let l:pos = 0 - while 1 - let l:bar = stridx(a:line, '|', l:pos) - if l:bar < 0 | break | endif - call add(l:out, l:bar + 1) - let l:pos = l:bar + 1 - endwhile - return l:out -endfunction function! nuwiki#commands#smart_tab() abort let l:line = getline('.') - if l:line !~# '^\s*|.*|\s*$' + if !s:is_table_row(l:line) return "\" endif let l:starts = s:table_cell_starts(l:line) @@ -752,37 +904,43 @@ function! nuwiki#commands#smart_tab() abort return "\" endif let l:col = col('.') - 1 - for l:s in l:starts - if l:s > l:col + 1 && l:s < len(l:line) - return "\" . (l:s + 1) . '|i' - endif - endfor - let l:n = len(l:starts) - 1 - let l:new_row = '|' . repeat(' |', l:n) - return "\" . l:new_row . "\0li" -endfunction - -function! nuwiki#commands#smart_shift_tab() abort - let l:line = getline('.') - if l:line !~# '^\s*|.*|\s*$' - return "\" - endif - let l:starts = s:table_cell_starts(l:line) - if len(l:starts) < 2 - return "\" - endif - let l:col = col('.') - 1 - let l:current_idx = -1 - let l:i = 0 + let l:current_idx = 1 + let l:i = 1 for l:s in l:starts if l:s <= l:col + 1 | let l:current_idx = l:i | endif let l:i += 1 endfor - if l:current_idx <= 0 + let l:target_idx = l:current_idx + 1 + if l:target_idx < len(l:starts) + return "\call nuwiki#commands#_table_jump_to_cell(" . line('.') . ", " . l:target_idx . ")\" + endif + " Past the final cell → add a fresh row below, cursor in its first cell. + let l:indent = matchstr(l:line, '^\s*') + let l:cursor_col = len(l:indent) + 2 + return "\call nuwiki#commands#_table_insert_row_below(" . line('.') . ", " . l:cursor_col . ")\" +endfunction + +function! nuwiki#commands#smart_shift_tab() abort + let l:line = getline('.') + if !s:is_table_row(l:line) return "\" endif - let l:target = l:starts[l:current_idx - 1] - return "\" . (l:target + 1) . '|i' + let l:starts = s:table_cell_starts(l:line) + if len(l:starts) < 2 + return "\" + endif + let l:col = col('.') - 1 + let l:current_idx = 0 + let l:i = 1 + for l:s in l:starts + if l:s <= l:col + 1 | let l:current_idx = l:i | endif + let l:i += 1 + endfor + if l:current_idx <= 1 + return "\" + endif + let l:target_idx = l:current_idx - 1 + return "\call nuwiki#commands#_table_jump_to_cell(" . line('.') . ", " . l:target_idx . ")\" endfunction " `:VimwikiNormalizeLink` — wrap the word at cursor as `[[word]]` diff --git a/lua/nuwiki/commands.lua b/lua/nuwiki/commands.lua index 68c29ee..6583d2a 100644 --- a/lua/nuwiki/commands.lua +++ b/lua/nuwiki/commands.lua @@ -582,28 +582,192 @@ function M.list_toggle_or_add_checkbox() vim.api.nvim_buf_set_lines(0, row - 1, row, false, { new_line }) end +-- ===== Pure-Lua table alignment ===== +-- +-- The LSP exposes `nuwiki.table.align` (see `M.table_align()` below), +-- but that path is async — by the time the workspace edit lands the +-- cursor has already been positioned by our keystrokes, which leaves +-- the cursor visually mid-cell after columns expand. For insert-mode +-- editing we replicate the alignment logic locally so we can realign +-- *before* placing the cursor, all in a single `vim.schedule` tick. +-- The algorithm matches `crates/nuwiki-lsp/src/commands.rs::ops:: +-- render_aligned_table`. + +local function is_table_row(line) + return line ~= nil and line:match('^%s*|.*|%s*$') ~= nil +end + +local function table_cell_starts(line) + -- Returns 1-based byte positions of each `|` separator. Cell N spans + -- (starts[N], starts[N+1]); placing the cursor at 0-based byte column + -- `starts[i]` lands one position past the i-th `|` (the first space + -- of the cell that the `|` opens). + local out = {} + local pos = 0 + while true do + local bar = line:find('|', pos + 1, true) + if not bar then break end + table.insert(out, bar) + pos = bar + end + return out +end + +local function find_table_range(row) + -- 1-based inclusive line numbers spanning the table that contains `row`. + local total = vim.api.nvim_buf_line_count(0) + local s = row + while s > 1 and is_table_row(vim.fn.getline(s - 1)) do + s = s - 1 + end + local e = row + while e < total and is_table_row(vim.fn.getline(e + 1)) do + e = e + 1 + end + return s, e +end + +local function parse_table_row(line) + local indent = line:match('^(%s*)') or '' + local body = line:sub(#indent + 1) + local segments = {} + local pos = 1 + while true do + local bar = body:find('|', pos, true) + if not bar then + table.insert(segments, body:sub(pos)) + break + end + table.insert(segments, body:sub(pos, bar - 1)) + pos = bar + 1 + end + -- Drop the empty piece before the opening `|` and the trailing piece + -- after the closing `|`. + if #segments > 0 and segments[1] == '' then table.remove(segments, 1) end + if #segments > 0 and segments[#segments]:match('^%s*$') then + table.remove(segments, #segments) + end + for i, c in ipairs(segments) do + segments[i] = c:match('^%s*(.-)%s*$') or '' + end + return indent, segments +end + +local function is_separator_cell(s) + return s ~= '' and s:match('^[%-:%s]+$') ~= nil +end + +local function is_separator_row(cells) + if #cells == 0 then return false end + for _, c in ipairs(cells) do + if not is_separator_cell(c) then return false end + end + return true +end + +local function content_width(s) + -- Match the LSP-side `chars().count()` so user-triggered `gqq` and + -- the auto-realign here agree on column widths. + local ok, w = pcall(vim.fn.strchars, s, 1) + if ok and type(w) == 'number' then return w end + return #s +end + +local function compute_table_widths(rows) + local widths = {} + local max_cols = 0 + for _, r in ipairs(rows) do + if #r.cells > max_cols then max_cols = #r.cells end + end + for i = 1, max_cols do widths[i] = 1 end + for _, r in ipairs(rows) do + if not is_separator_row(r.cells) then + for i, c in ipairs(r.cells) do + local w = content_width(c) + if w > widths[i] then widths[i] = w end + end + end + end + return widths +end + +local function render_table_row(indent, cells, widths, all_sep) + local out = { indent, '|' } + for i = 1, #widths do + local w = widths[i] + if all_sep then + table.insert(out, string.rep('-', w + 2)) + else + local c = cells[i] or '' + local pad = w - content_width(c) + if pad < 0 then pad = 0 end + table.insert(out, ' ' .. c .. string.rep(' ', pad) .. ' ') + end + table.insert(out, '|') + end + return table.concat(out) +end + +local function align_table_at(row) + local s, e = find_table_range(row) + local lines = vim.api.nvim_buf_get_lines(0, s - 1, e, false) + local rows = {} + for _, l in ipairs(lines) do + local indent, cells = parse_table_row(l) + table.insert(rows, { indent = indent, cells = cells }) + end + local widths = compute_table_widths(rows) + if #widths == 0 then return end + local new_lines = {} + for _, r in ipairs(rows) do + table.insert(new_lines, render_table_row(r.indent, r.cells, widths, is_separator_row(r.cells))) + end + vim.api.nvim_buf_set_lines(0, s - 1, e, false, new_lines) +end + +local function ensure_insert_mode() + if vim.api.nvim_get_mode().mode ~= 'i' then + vim.cmd('startinsert') + end +end + +local function schedule_insert_table_row_below(row) + -- After `align_table_at`, the row count of the table containing + -- `row` is fixed and `getline(row)` reflects the aligned content. + vim.schedule(function() + align_table_at(row) + local new_line = vim.fn.getline(row) + local starts = table_cell_starts(new_line) + if #starts < 2 then return end + local indent = new_line:match('^(%s*)') or '' + local cell_count = #starts - 1 + local new_row = indent .. '|' .. string.rep(' |', cell_count) + vim.api.nvim_buf_set_lines(0, row, row, false, { new_row }) + vim.api.nvim_win_set_cursor(0, { row + 1, #indent + 1 }) + ensure_insert_mode() + end) +end + --- Smart `` for insert mode — vimwiki parity for `:VimwikiReturn`. --- Used as an `` 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 `` land ---- at the right cursor position. +--- the buffer (textlock is active). Non-table branches encode their +--- behaviour as returned keystrokes; the table-row branch schedules a +--- post-textlock callback that aligns the table, inserts a fresh row, +--- and places the cursor inside the new row's first cell. 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('') local esc = tc('') - -- 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 `` + new_row, cursor sits at end of new row. Bounce to - -- normal, jump to col 0, step right past the leading `|`, re-enter - -- insert: `0li` puts the cursor immediately after the `|`. - return cr .. new_row .. esc .. '0li' + -- Table row → align existing rows, then add a fresh empty row below. + -- Doing this via `` + typed row keystrokes would split the + -- current line at the cursor and drag the trailing `|` onto the new + -- line — so we punt to `vim.schedule` instead. + if is_table_row(line) then + local cur = vim.api.nvim_win_get_cursor(0) + schedule_insert_table_row_below(cur[1]) + return '' end -- List item with marker? @@ -629,66 +793,76 @@ end --- Insert-mode `` / `` table navigation — vimwiki parity. --- Used as `` keymaps so we can fall back to a literal `` / ---- `` when the cursor isn't on a table row. Inside a row: ---- `` jumps to the next cell, creating a fresh row below the ---- current one if we were in the final cell. `` jumps to the ---- previous cell, no-op past the first. -local function table_cell_starts(line) - -- Returns 0-based byte offsets right after each `|` separator. - local out = {} - local pos = 0 - while true do - local bar = line:find('|', pos + 1, true) - if not bar then break end - table.insert(out, bar) -- 1-based: position immediately AFTER the | - pos = bar - end - return out -end +--- `` when the cursor isn't on a table row. Inside a row both +--- variants align the table first (so columns stay tight as cells +--- grow), then jump to the adjacent cell; `` past the final cell +--- adds a fresh row below, `` past the first cell is a no-op. function M.smart_tab() local line = vim.api.nvim_get_current_line() local function tc(s) return vim.api.nvim_replace_termcodes(s, true, false, true) end local tab = tc('') - if not line:match('^%s*|.*|%s*$') then + if not is_table_row(line) then return tab end local cell_starts = table_cell_starts(line) if #cell_starts < 2 then return tab end - local _, col = unpack(vim.api.nvim_win_get_cursor(0)) -- 0-based byte col - -- Find the next cell start strictly after the cursor's column. - for _, start in ipairs(cell_starts) do - if start > col + 1 and start < #line then - -- `` + jump to that col (`start + 1` 1-based → +1 for space - -- inside the cell), then `i` to re-enter insert. - local target = start + 1 - return tc('') .. tostring(target) .. '|i' - end + local cur = vim.api.nvim_win_get_cursor(0) + local row, col = cur[1], cur[2] + local current_idx = 1 + for i, start in ipairs(cell_starts) do + if start <= col + 1 then current_idx = i end end - -- Past last cell → insert a new row below, cursor in first cell. - local cell_count = #cell_starts - 1 - local new_row = '|' .. string.rep(' |', cell_count) - return tc('') .. new_row .. tc('') .. '0li' + local target_idx = current_idx + 1 + + vim.schedule(function() + align_table_at(row) + local new_line = vim.fn.getline(row) + local starts = table_cell_starts(new_line) + if target_idx < #starts then + -- Land at the first space of the target cell (byte position + -- equal to the 1-based pipe position). + vim.api.nvim_win_set_cursor(0, { row, starts[target_idx] }) + ensure_insert_mode() + else + local indent = new_line:match('^(%s*)') or '' + local cell_count = math.max(#starts - 1, 1) + local new_row = indent .. '|' .. string.rep(' |', cell_count) + vim.api.nvim_buf_set_lines(0, row, row, false, { new_row }) + vim.api.nvim_win_set_cursor(0, { row + 1, #indent + 1 }) + ensure_insert_mode() + end + end) + return '' end function M.smart_shift_tab() local line = vim.api.nvim_get_current_line() local function tc(s) return vim.api.nvim_replace_termcodes(s, true, false, true) end local stab = tc('') - if not line:match('^%s*|.*|%s*$') then + if not is_table_row(line) then return stab end local cell_starts = table_cell_starts(line) if #cell_starts < 2 then return stab end - local _, col = unpack(vim.api.nvim_win_get_cursor(0)) - -- Find the cell the cursor is in, then go to the *previous* cell. + local cur = vim.api.nvim_win_get_cursor(0) + local row, col = cur[1], cur[2] local current_idx for i, start in ipairs(cell_starts) do if start <= col + 1 then current_idx = i end end if not current_idx or current_idx <= 1 then return stab end - local target = cell_starts[current_idx - 1] - return tc('') .. tostring(target + 1) .. '|i' + local target_idx = current_idx - 1 + + vim.schedule(function() + align_table_at(row) + local new_line = vim.fn.getline(row) + local starts = table_cell_starts(new_line) + if target_idx < 1 or target_idx >= #starts then return end + vim.api.nvim_win_set_cursor(0, { row, starts[target_idx] }) + ensure_insert_mode() + end) + return '' end -- §13.1 Cluster B — table rewriters.