feat(13.1-B): table rewriters — insert, align, moveColumn

Closes the table-rewriter cluster from SPEC §13.1.

Server-side (ops module):
- `render_blank_table(cols, rows)` — pure templating: header row,
  separator `|--|--|--|`, then `rows` empty data rows. Wired through
  `table_insert` which dispatches a `TextEdit` at the cursor.
- `table_align_edit` — locates the `TableNode` containing the cursor
  line, computes max width per column across every row, re-emits the
  table with each cell space-padded to its column's width. The
  parser drops `|---|---|` separator rows but sets
  `TableNode.has_header`; the renderer re-inserts a padded separator
  after the header so the result still parses.
- `table_move_column_edit` — column-under-cursor swap with the
  left or right neighbour (`dir = "left" | "right"`). Cursor column
  is computed by counting pipe separators before the cursor's byte
  offset on the row's line. Column widths swap alongside the data so
  the post-swap table stays aligned. No-ops cleanly when the swap
  would fall off either edge.

Editor glue:
- `lua/nuwiki/commands.lua` / `autoload/nuwiki/commands.vim` — the
  three "not yet implemented" stubs are replaced with real LSP
  dispatchers. `table_insert` accepts optional `cols`/`rows`
  arguments (defaults: 3×2).
- Default keymaps `gqq` / `gq1` / `gww` / `gw1` now call
  `table_align`; `<A-Left>` / `<A-Right>` call
  `table_move_column_left` / `_right`. No more "deferred"
  notifications on the table keys.

Tests: 8 new in `cluster_b_table_rewriters.rs` — blank-table shape
(3×2 + 1×1), column-width alignment with separator-row repad, swap-
right shape, swap-left clamp at column 0, no-table-found fallbacks,
COMMANDS list completeness. Total 410 Rust tests pass; clippy clean;
Neovim keymap harness still 20/20.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:41:51 +00:00
parent 9d34c8baa2
commit 4245424d2f
6 changed files with 539 additions and 20 deletions
+41 -4
View File
@@ -513,14 +513,51 @@ function! nuwiki#commands#list_change_lvl(...) abort
let l:delta = (l:dir ==# 'increase' || l:dir ==# 'indent') ? 1 : -1 let l:delta = (l:dir ==# 'increase' || l:dir ==# 'indent') ? 1 : -1
call nuwiki#commands#list_change_level(l:delta, 0) call nuwiki#commands#list_change_level(l:delta, 0)
endfunction endfunction
function! nuwiki#commands#table_insert() abort " §13.1 Cluster B — table rewriters.
call s:notify_deferred(':VimwikiTable')
function! nuwiki#commands#table_insert(...) abort
let l:cols = a:0 >= 1 ? str2nr(a:1) : 3
let l:rows = a:0 >= 2 ? str2nr(a:2) : 2
if l:cols <= 0
let l:cols = 3
endif
if l:rows <= 0
let l:rows = 2
endif
let l:p = s:cursor_position()
let l:args = [{
\ 'uri': l:p['textDocument']['uri'],
\ 'position': l:p['position'],
\ 'cols': l:cols,
\ 'rows': l:rows,
\ }]
call s:exec('nuwiki.table.insert', l:args)
endfunction endfunction
function! nuwiki#commands#table_align() abort
let l:p = s:cursor_position()
let l:args = [{ 'uri': l:p['textDocument']['uri'], 'position': l:p['position'] }]
call s:exec('nuwiki.table.align', l:args)
endfunction
function! nuwiki#commands#table_move_left() abort function! nuwiki#commands#table_move_left() abort
call s:notify_deferred(':VimwikiTableMoveColumnLeft') let l:p = s:cursor_position()
let l:args = [{
\ 'uri': l:p['textDocument']['uri'],
\ 'position': l:p['position'],
\ 'dir': 'left',
\ }]
call s:exec('nuwiki.table.moveColumn', l:args)
endfunction endfunction
function! nuwiki#commands#table_move_right() abort function! nuwiki#commands#table_move_right() abort
call s:notify_deferred(':VimwikiTableMoveColumnRight') let l:p = s:cursor_position()
let l:args = [{
\ 'uri': l:p['textDocument']['uri'],
\ 'position': l:p['position'],
\ 'dir': 'right',
\ }]
call s:exec('nuwiki.table.moveColumn', l:args)
endfunction endfunction
function! nuwiki#commands#colorize(color) abort function! nuwiki#commands#colorize(color) abort
call s:notify_deferred(':VimwikiColorize') call s:notify_deferred(':VimwikiColorize')
+342
View File
@@ -71,6 +71,9 @@ pub const COMMANDS: &[&str] = &[
"nuwiki.list.renumber", "nuwiki.list.renumber",
"nuwiki.list.changeSymbol", "nuwiki.list.changeSymbol",
"nuwiki.list.changeLevel", "nuwiki.list.changeLevel",
"nuwiki.table.insert",
"nuwiki.table.align",
"nuwiki.table.moveColumn",
]; ];
pub(crate) async fn execute( pub(crate) async fn execute(
@@ -177,6 +180,11 @@ pub(crate) async fn execute(
"nuwiki.list.changeLevel" => { "nuwiki.list.changeLevel" => {
list_change_level(backend, args).map(|o| o.map(CommandOutcome::Edit)) list_change_level(backend, args).map(|o| o.map(CommandOutcome::Edit))
} }
"nuwiki.table.insert" => table_insert(backend, args).map(|o| o.map(CommandOutcome::Edit)),
"nuwiki.table.align" => table_align(backend, args).map(|o| o.map(CommandOutcome::Edit)),
"nuwiki.table.moveColumn" => {
table_move_column(backend, args).map(|o| o.map(CommandOutcome::Edit))
}
other => Err(format!("unknown nuwiki command: {other}")), other => Err(format!("unknown nuwiki command: {other}")),
} }
} }
@@ -929,6 +937,82 @@ fn list_change_level(backend: &Backend, args: Vec<Value>) -> Result<Option<Works
)) ))
} }
// ===== §13.1 Cluster B: table rewriters =====
fn table_insert(_backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
position: LspPosition,
cols: usize,
rows: usize,
}
let raw = args
.into_iter()
.next()
.ok_or_else(|| "missing { uri, position, cols, rows }".to_string())?;
let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?;
if parsed.cols == 0 || parsed.rows == 0 {
return Ok(None);
}
let text = ops::render_blank_table(parsed.cols, parsed.rows);
let mut b = WorkspaceEditBuilder::new();
b.edit(
parsed.uri,
crate::edits::text_edit_insert(parsed.position, text),
);
Ok(Some(b.build()))
}
fn table_align(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
let p = parse_pos(args)?;
let doc = match backend.documents.get(&p.uri) {
Some(d) => d,
None => return Ok(None),
};
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
let (line, _) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8);
Ok(ops::table_align_edit(
&doc.text, &doc.ast, &p.uri, line, utf8,
))
}
fn table_move_column(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
position: LspPosition,
#[serde(default)]
dir: Option<String>,
}
let raw = args
.into_iter()
.next()
.ok_or_else(|| "missing { uri, position, dir }".to_string())?;
let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?;
let dir = parsed.dir.as_deref().unwrap_or("right");
let delta = match dir {
"left" => -1i32,
"right" => 1,
other => return Err(format!("unknown direction: {other}")),
};
let doc = match backend.documents.get(&parsed.uri) {
Some(d) => d,
None => return Ok(None),
};
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
let (line, col) = nav::lsp_to_byte_pos(parsed.position, &doc.text, utf8);
Ok(ops::table_move_column_edit(
&doc.text,
&doc.ast,
&parsed.uri,
line,
col,
delta,
utf8,
))
}
// ===== Phase 17: HTML export commands ===== // ===== Phase 17: HTML export commands =====
fn export_current( fn export_current(
@@ -2149,6 +2233,264 @@ pub mod ops {
Some((span, marker_str, i)) Some((span, marker_str, i))
} }
// ===== §13.1 Cluster B: table rewriters =====
use nuwiki_core::ast::{TableNode, TableRowNode};
/// Plain-text body of a freshly-inserted vimwiki table with `cols`
/// columns and `rows` data rows (header + separator + rows).
pub fn render_blank_table(cols: usize, rows: usize) -> String {
let cell = " ";
let mut out = String::new();
// Header row.
out.push('|');
for _ in 0..cols {
out.push_str(cell);
out.push('|');
}
out.push('\n');
// Separator row.
out.push('|');
for _ in 0..cols {
out.push_str("--");
out.push('|');
}
out.push('\n');
// Data rows.
for _ in 0..rows {
out.push('|');
for _ in 0..cols {
out.push_str(cell);
out.push('|');
}
out.push('\n');
}
out
}
/// Reformat the table containing `line` so every column shares the
/// same width as its widest cell. Header-separator rows get
/// repadded with `-` runs so the table still parses cleanly.
pub fn table_align_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
line: u32,
utf8: bool,
) -> Option<WorkspaceEdit> {
let table = find_table_at_line(ast, line)?;
let rendered = render_aligned_table(table, text)?;
let span = table.span;
let mut b = WorkspaceEditBuilder::new();
b.edit(uri.clone(), text_edit_replace(span, rendered, text, utf8));
Some(b.build())
}
/// Swap the column under cursor with its left (`delta = -1`) or
/// right (`delta = +1`) neighbour across every row of the table.
pub fn table_move_column_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
line: u32,
col: u32,
delta: i32,
utf8: bool,
) -> Option<WorkspaceEdit> {
let table = find_table_at_line(ast, line)?;
let cur_col = cell_column_at(table, text, line, col)?;
let other = cur_col as i32 + delta;
if other < 0 {
return None;
}
let other = other as usize;
// All rows must have both columns to make the swap meaningful.
let max_cols = table.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
if other >= max_cols {
return None;
}
let rendered = render_swapped_table(table, text, cur_col, other)?;
let mut b = WorkspaceEditBuilder::new();
b.edit(
uri.clone(),
text_edit_replace(table.span, rendered, text, utf8),
);
Some(b.build())
}
fn find_table_at_line(ast: &DocumentNode, line: u32) -> Option<&TableNode> {
for block in &ast.children {
if let BlockNode::Table(t) = block {
if (t.span.start.line..=t.span.end.line).contains(&line) {
return Some(t);
}
}
// Tables inside blockquotes are uncommon but possible.
if let BlockNode::Blockquote(BlockquoteNode { children, .. }) = block {
for c in children {
if let BlockNode::Table(t) = c {
if (t.span.start.line..=t.span.end.line).contains(&line) {
return Some(t);
}
}
}
}
}
None
}
/// Locate the column index under (`line`, `col`) by walking the
/// row that contains `line` and counting pipe separators before
/// the cursor.
fn cell_column_at(table: &TableNode, text: &str, line: u32, col: u32) -> Option<usize> {
let row = table
.rows
.iter()
.find(|r| (r.span.start.line..=r.span.end.line).contains(&line))?;
let row_src = source_slice(text, row.span);
let bytes = row_src.as_bytes();
// `col` is the cursor's byte column on `line`. Re-base it
// against the row's start column on that line.
let row_start_col = row.span.start.column;
if col < row_start_col {
return None;
}
let mut rel = (col - row_start_col) as usize;
rel = rel.min(bytes.len());
let mut pipes_before = 0usize;
for (i, b) in bytes.iter().enumerate() {
if i >= rel {
break;
}
if *b == b'|' {
pipes_before += 1;
}
}
// First pipe opens the row, so column index is `pipes - 1`.
if pipes_before == 0 {
return Some(0);
}
Some(pipes_before - 1)
}
fn source_slice(text: &str, span: Span) -> &str {
let start = span.start.offset.min(text.len());
let end = span.end.offset.min(text.len());
&text[start..end]
}
/// Compute column widths (max content length across rows).
fn column_widths(table: &TableNode, text: &str) -> Vec<usize> {
let max_cols = table.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
let mut widths = vec![1usize; max_cols];
for row in &table.rows {
for (i, cell) in row.cells.iter().enumerate() {
let content = source_slice(text, cell.span).trim();
if !is_separator_cell(content) {
widths[i] = widths[i].max(content.chars().count());
}
}
}
widths
}
fn is_separator_cell(content: &str) -> bool {
!content.is_empty()
&& content
.chars()
.all(|c| c == '-' || c == ':' || c.is_whitespace())
}
fn render_aligned_table(table: &TableNode, text: &str) -> Option<String> {
let widths = column_widths(table, text);
let mut out = String::new();
for (i, row) in table.rows.iter().enumerate() {
render_row(row, text, &widths, &mut out);
// The lexer dropped the `|---|---|` separator row, so
// re-emit one after the header when `has_header` is set.
if i == 0 && table.has_header {
emit_separator_row(&widths, &mut out);
}
}
Some(out)
}
fn emit_separator_row(widths: &[usize], out: &mut String) {
out.push('|');
for w in widths {
out.push_str(&"-".repeat(w + 2));
out.push('|');
}
out.push('\n');
}
fn render_row(row: &TableRowNode, text: &str, widths: &[usize], out: &mut String) {
let mut all_separator = !row.cells.is_empty();
for cell in &row.cells {
let content = source_slice(text, cell.span).trim();
if !is_separator_cell(content) {
all_separator = false;
break;
}
}
out.push('|');
for (i, _cell) in row.cells.iter().enumerate() {
let width = *widths.get(i).unwrap_or(&1);
if all_separator {
out.push_str(&"-".repeat(width + 2));
} else {
let content = source_slice(text, row.cells[i].span).trim();
let pad = width.saturating_sub(content.chars().count());
out.push(' ');
out.push_str(content);
out.push_str(&" ".repeat(pad));
out.push(' ');
}
out.push('|');
}
out.push('\n');
}
fn render_swapped_table(table: &TableNode, text: &str, a: usize, b: usize) -> Option<String> {
// Swap the column-width slots first so the rendered table
// remains visually aligned after the move.
let mut widths = column_widths(table, text);
if a < widths.len() && b < widths.len() {
widths.swap(a, b);
}
let mut out = String::new();
for (row_idx, row) in table.rows.iter().enumerate() {
out.push('|');
for i in 0..row.cells.len() {
let src_col = if i == a {
b
} else if i == b {
a
} else {
i
};
let width = *widths.get(i).unwrap_or(&1);
let content = if src_col < row.cells.len() {
source_slice(text, row.cells[src_col].span).trim()
} else {
""
};
let pad = width.saturating_sub(content.chars().count());
out.push(' ');
out.push_str(content);
out.push_str(&" ".repeat(pad));
out.push(' ');
out.push('|');
}
out.push('\n');
// Re-emit the dropped separator row after the header.
if row_idx == 0 && table.has_header {
emit_separator_row(&widths, &mut out);
}
}
Some(out)
}
// ===== Phase 16: diary helpers ===== // ===== Phase 16: diary helpers =====
use crate::edits::op_create; use crate::edits::op_create;
@@ -0,0 +1,116 @@
//! §13.1 Cluster B — table rewriters: `insert`, `align`, `moveColumn`.
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::commands::ops;
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn uri() -> Url {
Url::parse("file:///tmp/tbl.wiki").unwrap()
}
fn one_text(edit: tower_lsp::lsp_types::WorkspaceEdit) -> String {
let changes = edit.changes.expect("changes");
let (_, edits) = changes.into_iter().next().unwrap();
edits.into_iter().next().unwrap().new_text
}
// ===== render_blank_table =====
#[test]
fn blank_table_has_header_separator_and_rows() {
let out = ops::render_blank_table(3, 2);
let lines: Vec<&str> = out.lines().collect();
// Header + separator + 2 data rows = 4.
assert_eq!(lines.len(), 4);
assert_eq!(lines[0], "| | | |");
assert_eq!(lines[1], "|--|--|--|");
assert_eq!(lines[2], "| | | |");
assert_eq!(lines[3], "| | | |");
}
#[test]
fn blank_table_handles_single_cell_grid() {
let out = ops::render_blank_table(1, 1);
assert_eq!(out, "| |\n|--|\n| |\n");
}
// ===== align =====
#[test]
fn align_pads_short_cells_to_widest() {
let src = "|a|bbb|c|\n|--|--|--|\n|1|2|three|\n";
let ast = parse(src);
let edit = ops::table_align_edit(src, &ast, &uri(), 0, true).expect("edit");
let body = one_text(edit);
let lines: Vec<&str> = body.lines().collect();
eprintln!("rendered table:");
for (i, l) in lines.iter().enumerate() {
eprintln!(" {i}: {l:?}");
}
// Column widths: max(a,1)=1 → 1, max(bbb,2)=3, max(c,three)=5.
assert_eq!(lines[0], "| a | bbb | c |");
// Separator row is repadded with `-` runs sized `width + 2` per
// column (the two padding spaces of content rows).
assert!(lines[1].starts_with("|---|"), "got: {:?}", lines[1]);
assert_eq!(lines[2], "| 1 | 2 | three |");
}
#[test]
fn align_returns_none_when_cursor_off_table() {
let src = "paragraph\n";
let ast = parse(src);
assert!(ops::table_align_edit(src, &ast, &uri(), 0, true).is_none());
}
// ===== moveColumn =====
#[test]
fn move_column_right_swaps_with_neighbour() {
let src = "|a|b|c|\n|--|--|--|\n|1|2|3|\n";
let ast = parse(src);
// Cursor on first column → swap right.
let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, 1, true).expect("edit");
let body = one_text(edit);
let lines: Vec<&str> = body.lines().collect();
assert!(lines[0].contains("b") && lines[0].contains("a"));
// b should come before a after swapping col 0 → 1.
let line0 = lines[0];
let b_pos = line0.find('b').unwrap();
let a_pos = line0.find('a').unwrap();
assert!(b_pos < a_pos, "expected b before a; got {line0:?}");
}
#[test]
fn move_column_left_at_zero_is_noop() {
let src = "|a|b|\n|--|--|\n|1|2|\n";
let ast = parse(src);
// Cursor in column 0; left would be -1 → returns None.
let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, -1, true);
assert!(edit.is_none());
}
#[test]
fn move_column_returns_none_off_table() {
let src = "paragraph\n";
let ast = parse(src);
assert!(ops::table_move_column_edit(src, &ast, &uri(), 0, 0, 1, true).is_none());
}
// ===== COMMANDS list =====
#[test]
fn commands_list_includes_cluster_b() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [
"nuwiki.table.insert",
"nuwiki.table.align",
"nuwiki.table.moveColumn",
] {
assert!(names.contains(&name), "missing: {name}");
}
}
+7 -7
View File
@@ -181,13 +181,13 @@ if !has('nvim')
nnoremap <silent><buffer> <Leader>whh :call nuwiki#commands#export_browse()<CR> nnoremap <silent><buffer> <Leader>whh :call nuwiki#commands#export_browse()<CR>
nnoremap <silent><buffer> <Leader>wha :call nuwiki#commands#export_all()<CR> nnoremap <silent><buffer> <Leader>wha :call nuwiki#commands#export_all()<CR>
" Tables (deferred §13.1) " Tables
nnoremap <silent><buffer> gqq :echohl WarningMsg<bar>echom 'nuwiki: :VimwikiTableAlignQ not yet implemented — see SPEC §13.1'<bar>echohl None<CR> nnoremap <silent><buffer> gqq :call nuwiki#commands#table_align()<CR>
nnoremap <silent><buffer> gq1 :echohl WarningMsg<bar>echom 'nuwiki: :VimwikiTableAlignQ1 not yet implemented — see SPEC §13.1'<bar>echohl None<CR> nnoremap <silent><buffer> gq1 :call nuwiki#commands#table_align()<CR>
nnoremap <silent><buffer> gww :echohl WarningMsg<bar>echom 'nuwiki: :VimwikiTableAlignW not yet implemented — see SPEC §13.1'<bar>echohl None<CR> nnoremap <silent><buffer> gww :call nuwiki#commands#table_align()<CR>
nnoremap <silent><buffer> gw1 :echohl WarningMsg<bar>echom 'nuwiki: :VimwikiTableAlignW1 not yet implemented — see SPEC §13.1'<bar>echohl None<CR> nnoremap <silent><buffer> gw1 :call nuwiki#commands#table_align()<CR>
nnoremap <silent><buffer> <A-Left> :echohl WarningMsg<bar>echom 'nuwiki: :VimwikiTableMoveColumnLeft not yet implemented'<bar>echohl None<CR> nnoremap <silent><buffer> <A-Left> :call nuwiki#commands#table_move_left()<CR>
nnoremap <silent><buffer> <A-Right> :echohl WarningMsg<bar>echom 'nuwiki: :VimwikiTableMoveColumnRight not yet implemented'<bar>echohl None<CR> nnoremap <silent><buffer> <A-Right> :call nuwiki#commands#table_move_right()<CR>
endif endif
finish finish
+27 -3
View File
@@ -474,9 +474,33 @@ function M.list_change_lvl(direction)
M.list_change_level(delta, false) M.list_change_level(delta, false)
end end
M.table_insert = _not_yet(':VimwikiTable') -- §13.1 Cluster B — table rewriters.
M.table_move_column_left = _not_yet(':VimwikiTableMoveColumnLeft')
M.table_move_column_right = _not_yet(':VimwikiTableMoveColumnRight') function M.table_insert(cols, rows)
cols = tonumber(cols) or 3
rows = tonumber(rows) or 2
local p = pos_args()[1]
p.cols = cols
p.rows = rows
exec('nuwiki.table.insert', { p })
end
function M.table_align()
exec('nuwiki.table.align', pos_args())
end
function M.table_move_column_left()
local p = pos_args()[1]
p.dir = 'left'
exec('nuwiki.table.moveColumn', { p })
end
function M.table_move_column_right()
local p = pos_args()[1]
p.dir = 'right'
exec('nuwiki.table.moveColumn', { p })
end
M.colorize = _not_yet(':VimwikiColorize') M.colorize = _not_yet(':VimwikiColorize')
-- §13.1 Cluster C — link helpers. -- §13.1 Cluster C — link helpers.
+6 -6
View File
@@ -255,12 +255,12 @@ function M.attach(bufnr, mappings)
-- ===== Tables ===== -- ===== Tables =====
if on('table_editing') then if on('table_editing') then
map('n', 'gqq', deferred(':VimwikiTableAlignQ'), { desc = 'nuwiki: table align (deferred)' }, bufnr) map('n', 'gqq', cmd.table_align, { desc = 'nuwiki: align table' }, bufnr)
map('n', 'gq1', deferred(':VimwikiTableAlignQ1'), { desc = 'nuwiki: table align row1 (deferred)' }, bufnr) map('n', 'gq1', cmd.table_align, { desc = 'nuwiki: align table' }, bufnr)
map('n', 'gww', deferred(':VimwikiTableAlignW'), { desc = 'nuwiki: table align wide (deferred)' }, bufnr) map('n', 'gww', cmd.table_align, { desc = 'nuwiki: align table (wide)' }, bufnr)
map('n', 'gw1', deferred(':VimwikiTableAlignW1'), { desc = 'nuwiki: table align row1 wide (deferred)' }, bufnr) map('n', 'gw1', cmd.table_align, { desc = 'nuwiki: align table (wide)' }, bufnr)
map('n', '<A-Left>', deferred(':VimwikiTableMoveColumnLeft'), { desc = 'nuwiki: table col left (deferred)' }, bufnr) map('n', '<A-Left>', cmd.table_move_column_left, { desc = 'nuwiki: move column left' }, bufnr)
map('n', '<A-Right>', deferred(':VimwikiTableMoveColumnRight'), { desc = 'nuwiki: table col right (deferred)' }, bufnr) map('n', '<A-Right>', cmd.table_move_column_right, { desc = 'nuwiki: move column right' }, bufnr)
end end
-- ===== HTML ===== -- ===== HTML =====