From 4c187fee3c52ecee30219769e2c9b544a896584d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Thu, 2 Jul 2026 11:56:59 +0000 Subject: [PATCH] fix(tables): don't split a cell on a `|` inside a link/code/escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table auto-alignment scanned for cell separators with a naive find('|'), so a cell containing a vimwiki link `[[url|title]]` was split at the link's internal pipe — mangling both the link (`[[url` / `title]]`) and the table (a phantom extra column). Same bug in both the Lua (Neovim) and VimL (Vim) table code, in both table_cell_starts and parse_table_row. Add a shared cell-separator scanner (cell_bar_positions) that treats `|` as literal — not a separator — when it's inside `[[…]]` wikilinks, `{{…}}` transclusions, `` `…` `` inline code, or backslash-escaped (`\|`), matching vimwiki. Both scan functions in each client now use it. Regression tests added on both clients (cr.table_link_cell_pipe_not_a_separator); full harness green (Neovim 308, Vim 302). Co-Authored-By: Claude Opus 4.8 --- autoload/nuwiki/commands.vim | 53 ++++++++++++++++++-------- development/tests/test-keymaps-vim.vim | 8 ++++ development/tests/test-keymaps.lua | 10 +++++ lua/nuwiki/commands.lua | 50 ++++++++++++++++-------- 4 files changed, 90 insertions(+), 31 deletions(-) diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index 265c5c6..2926ab1 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -1159,19 +1159,44 @@ function! s:is_table_row(line) abort return nuwiki#util#is_table_row(a:line) endfunction +" 0-based byte offsets of the cell-separator `|` in `s`. Pipes that are literal +" cell content are skipped: those inside `[[…]]` wikilinks, `{{…}}` +" transclusions, `` `…` `` inline code, and backslash-escaped `\|`. Byte-based +" (strpart/stridx) to match the rest of the table code. Keeps a `[[url|title]]` +" link in one cell instead of splitting on its internal pipe. +function! s:cell_bar_positions(s) abort + let l:out = [] + let l:i = 0 + let l:n = strlen(a:s) + while l:i < l:n + let l:c = strpart(a:s, l:i, 1) + let l:two = strpart(a:s, l:i, 2) + if l:c ==# '\' + let l:i += 2 + elseif l:c ==# '`' + let l:close = stridx(a:s, '`', l:i + 1) + let l:i = l:close < 0 ? l:n : l:close + 1 + elseif l:two ==# '[[' + let l:close = stridx(a:s, ']]', l:i + 2) + let l:i = l:close < 0 ? l:n : l:close + 2 + elseif l:two ==# '{{' + let l:close = stridx(a:s, '}}', l:i + 2) + let l:i = l:close < 0 ? l:n : l:close + 2 + elseif l:c ==# '|' + call add(l:out, l:i) + let l:i += 1 + else + let l:i += 1 + endif + endwhile + return l:out +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 + return map(s:cell_bar_positions(a:line), 'v:val + 1') endfunction function! s:find_table_range(row) abort @@ -1192,15 +1217,11 @@ function! s:parse_table_row(line) abort 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 + for l:bar in s:cell_bar_positions(l:body) call add(l:segments, strpart(l:body, l:pos, l:bar - l:pos)) let l:pos = l:bar + 1 - endwhile + endfor + call add(l:segments, strpart(l:body, l:pos)) " Drop the empty piece before the opening `|` and the trailing piece " after the closing `|`. if len(l:segments) > 0 && l:segments[0] ==# '' diff --git a/development/tests/test-keymaps-vim.vim b/development/tests/test-keymaps-vim.vim index 8d83251..a7fe45a 100644 --- a/development/tests/test-keymaps-vim.vim +++ b/development/tests/test-keymaps-vim.vim @@ -336,6 +336,14 @@ call s:run('cr.adds_new_table_row', { \ 'keys': "A\x\", \ 'expect_lines': ['| a | b |', '|x | |'], \ }) +" Regression: a `|` inside `[[u|t]]` must not split the cell — the link stays +" intact and the row keeps 2 columns (fresh row has 2 cells, not 3). +call s:run('cr.table_link_cell_pipe_not_a_separator', { + \ 'lines': ['| a | [[u|t]] |'], + \ 'cursor': [1, 4], + \ 'keys': "A\x\", + \ 'expect_lines': ['| a | [[u|t]] |', '|x | |'], + \ }) " Insert : multiline list item — continue with NO marker, aligned under " the text ("- " → 2 cols; "- [ ] " → 6 cols). call s:run('cr.shift_cr_multiline', { diff --git a/development/tests/test-keymaps.lua b/development/tests/test-keymaps.lua index c0e68ae..d0abe29 100644 --- a/development/tests/test-keymaps.lua +++ b/development/tests/test-keymaps.lua @@ -735,6 +735,16 @@ vim.defer_fn(function() expect_lines = { '| a | b |', '|x | |' }, wait_ms = 100, }) + run('cr.table_link_cell_pipe_not_a_separator', { + -- Regression: the `|` inside `[[u|t]]` must NOT split the cell, so the + -- link stays intact and the row keeps its 2 columns (a 2-col fresh row, + -- not the mangled 3-col one the old naive `|` scan produced). + lines = { '| a | [[u|t]] |' }, + cursor = { 1, 4 }, + keys = 'Ax', + expect_lines = { '| a | [[u|t]] |', '|x | |' }, + wait_ms = 100, + }) -- ===== Insert-mode table navigation (Cluster 6) ===== diff --git a/lua/nuwiki/commands.lua b/lua/nuwiki/commands.lua index e4bb585..ed67924 100644 --- a/lua/nuwiki/commands.lua +++ b/lua/nuwiki/commands.lua @@ -821,20 +821,44 @@ end local is_table_row = require('nuwiki.util').is_table_row +-- 1-based byte positions of the cell-separator `|` characters in `s`. Pipes +-- that are literal cell content are skipped: those inside `[[…]]` wikilinks, +-- `{{…}}` transclusions, `` `…` `` inline code, and backslash-escaped `\|`. +-- This keeps a `[[url|title]]` link in one cell instead of splitting on its +-- internal pipe. +local function cell_bar_positions(s) + local out = {} + local i = 1 + local n = #s + while i <= n do + local c = s:sub(i, i) + if c == '\\' then + i = i + 2 -- skip the escaped char (e.g. `\|`) + elseif c == '`' then + local close = s:find('`', i + 1, true) + i = (close or n) + 1 + elseif s:sub(i, i + 1) == '[[' then + local close = s:find(']]', i + 2, true) + i = close and close + 2 or n + 1 + elseif s:sub(i, i + 1) == '{{' then + local close = s:find('}}', i + 2, true) + i = close and close + 2 or n + 1 + elseif c == '|' then + table.insert(out, i) + i = i + 1 + else + i = i + 1 + end + end + return out +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 + return cell_bar_positions(line) end local function find_table_range(row) @@ -856,15 +880,11 @@ local function parse_table_row(line) 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 + for _, bar in ipairs(cell_bar_positions(body)) do table.insert(segments, body:sub(pos, bar - 1)) pos = bar + 1 end + table.insert(segments, body:sub(pos)) -- 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