3 Commits

Author SHA1 Message Date
gffranco 5375e30bda fix(tables): don't skip escaped \| in cell scan — match the server
CI / editor tests (push) Successful in 46s
The server lexer (nuwiki-rs) splits table cells on a backslash-escaped `\|`
(it can't skip it yet without leaving a stray backslash in the output), but
the client's cell_bar_positions was skipping `\|`. That made the editor
auto-align keep `a\|b` as one cell while HTML export split it into two.

Drop the `\|` case from both clients so buffer alignment and export agree.
`[[…]]` / `{{…}}` / `` `code` `` pipes are still treated as literal. A
coordinated `\|` follow-up (server unescaping + client re-adding the skip)
is tracked in nuwiki-rs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:43:15 +00:00
gitea-actions 51e857f3c6 chore(release): 0.5.2
CI / editor tests (push) Successful in 44s
2026-07-02 12:02:20 +00:00
gffranco 4c187fee3c fix(tables): don't split a cell on a | inside a link/code/escape
CI / editor tests (push) Successful in 46s
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 <noreply@anthropic.com>
2026-07-02 11:57:16 +00:00
5 changed files with 93 additions and 32 deletions
+38 -16
View File
@@ -1159,19 +1159,45 @@ function! s:is_table_row(line) abort
return nuwiki#util#is_table_row(a:line) return nuwiki#util#is_table_row(a:line)
endfunction endfunction
" 0-based byte offsets of the cell-separator `|` in `s`. Pipes that are literal
" cell content are skipped: those inside `[[…]]` wikilinks, `{{…}}`
" transclusions, and `` `…` `` inline code. 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.
"
" Backslash-escaped `\|` is intentionally NOT skipped, to match the server
" lexer (nuwiki-rs). See the nuwiki-rs issue for the coordinated `\|` follow-up.
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: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 function! s:table_cell_starts(line) abort
" 1-based byte positions of each `|`. Cursor lands one past pipe N at " 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 " 1-based byte (starts[N] + 1) — that's the first space inside the
" cell the pipe opens. " cell the pipe opens.
let l:out = [] return map(s:cell_bar_positions(a:line), 'v:val + 1')
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 endfunction
function! s:find_table_range(row) abort function! s:find_table_range(row) abort
@@ -1192,15 +1218,11 @@ function! s:parse_table_row(line) abort
let l:body = strpart(a:line, len(l:indent)) let l:body = strpart(a:line, len(l:indent))
let l:segments = [] let l:segments = []
let l:pos = 0 let l:pos = 0
while 1 for l:bar in s:cell_bar_positions(l:body)
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)) call add(l:segments, strpart(l:body, l:pos, l:bar - l:pos))
let l:pos = l:bar + 1 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 " Drop the empty piece before the opening `|` and the trailing piece
" after the closing `|`. " after the closing `|`.
if len(l:segments) > 0 && l:segments[0] ==# '' if len(l:segments) > 0 && l:segments[0] ==# ''
+8
View File
@@ -336,6 +336,14 @@ call s:run('cr.adds_new_table_row', {
\ 'keys': "A\<CR>x\<Esc>", \ 'keys': "A\<CR>x\<Esc>",
\ 'expect_lines': ['| a | b |', '|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\<CR>x\<Esc>",
\ 'expect_lines': ['| a | [[u|t]] |', '|x | |'],
\ })
" Insert <S-CR>: multiline list item — continue with NO marker, aligned under " Insert <S-CR>: multiline list item — continue with NO marker, aligned under
" the text ("- " → 2 cols; "- [ ] " → 6 cols). " the text ("- " → 2 cols; "- [ ] " → 6 cols).
call s:run('cr.shift_cr_multiline', { call s:run('cr.shift_cr_multiline', {
+10
View File
@@ -735,6 +735,16 @@ vim.defer_fn(function()
expect_lines = { '| a | b |', '|x | |' }, expect_lines = { '| a | b |', '|x | |' },
wait_ms = 100, 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 = 'A<CR>x<Esc>',
expect_lines = { '| a | [[u|t]] |', '|x | |' },
wait_ms = 100,
})
-- ===== Insert-mode table navigation (Cluster 6) ===== -- ===== Insert-mode table navigation (Cluster 6) =====
+36 -15
View File
@@ -821,20 +821,45 @@ end
local is_table_row = require('nuwiki.util').is_table_row 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, and `` `…` `` inline code. This keeps a
-- `[[url|title]]` link in one cell instead of splitting on its internal pipe.
--
-- Backslash-escaped `\|` is intentionally NOT skipped, to match the server
-- lexer (nuwiki-rs), which can't skip it yet without leaving a stray backslash
-- in the output. See nuwiki-rs issue for the coordinated `\|` follow-up.
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
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) local function table_cell_starts(line)
-- Returns 1-based byte positions of each `|` separator. Cell N spans -- 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[N], starts[N+1]); placing the cursor at 0-based byte column
-- `starts[i]` lands one position past the i-th `|` (the first space -- `starts[i]` lands one position past the i-th `|` (the first space
-- of the cell that the `|` opens). -- of the cell that the `|` opens).
local out = {} return cell_bar_positions(line)
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 end
local function find_table_range(row) local function find_table_range(row)
@@ -856,15 +881,11 @@ local function parse_table_row(line)
local body = line:sub(#indent + 1) local body = line:sub(#indent + 1)
local segments = {} local segments = {}
local pos = 1 local pos = 1
while true do for _, bar in ipairs(cell_bar_positions(body)) 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)) table.insert(segments, body:sub(pos, bar - 1))
pos = bar + 1 pos = bar + 1
end end
table.insert(segments, body:sub(pos))
-- Drop the empty piece before the opening `|` and the trailing piece -- Drop the empty piece before the opening `|` and the trailing piece
-- after the closing `|`. -- after the closing `|`.
if #segments > 0 and segments[1] == '' then table.remove(segments, 1) end if #segments > 0 and segments[1] == '' then table.remove(segments, 1) end
+1 -1
View File
@@ -16,7 +16,7 @@ let g:loaded_nuwiki = 1
" Plugin version, surfaced by :VimwikiShowVersion / :NuwikiShowVersion. " Plugin version, surfaced by :VimwikiShowVersion / :NuwikiShowVersion.
" Set before the Neovim early-return below so both clients see the global. " Set before the Neovim early-return below so both clients see the global.
if !exists('g:nuwiki_version') if !exists('g:nuwiki_version')
let g:nuwiki_version = '0.5.1' let g:nuwiki_version = '0.5.2'
endif endif
" Resolve the plugin root NOW, while this script is being sourced and " Resolve the plugin root NOW, while this script is being sourced and