From 4245424d2f0beb83a324da177c0d89f677d97d9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Tue, 12 May 2026 14:41:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(13.1-B):=20table=20rewriters=20=E2=80=94?= =?UTF-8?q?=20insert,=20align,=20moveColumn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`; `` / `` 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) --- autoload/nuwiki/commands.vim | 45 ++- crates/nuwiki-lsp/src/commands.rs | 342 ++++++++++++++++++ .../tests/cluster_b_table_rewriters.rs | 116 ++++++ ftplugin/vimwiki.vim | 14 +- lua/nuwiki/commands.lua | 30 +- lua/nuwiki/keymaps.lua | 12 +- 6 files changed, 539 insertions(+), 20 deletions(-) create mode 100644 crates/nuwiki-lsp/tests/cluster_b_table_rewriters.rs diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index c99e293..5fd507d 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -513,14 +513,51 @@ function! nuwiki#commands#list_change_lvl(...) abort let l:delta = (l:dir ==# 'increase' || l:dir ==# 'indent') ? 1 : -1 call nuwiki#commands#list_change_level(l:delta, 0) endfunction -function! nuwiki#commands#table_insert() abort - call s:notify_deferred(':VimwikiTable') +" §13.1 Cluster B — table rewriters. + +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 + +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 - 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 + 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 function! nuwiki#commands#colorize(color) abort call s:notify_deferred(':VimwikiColorize') diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 381398b..8db62bc 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -71,6 +71,9 @@ pub const COMMANDS: &[&str] = &[ "nuwiki.list.renumber", "nuwiki.list.changeSymbol", "nuwiki.list.changeLevel", + "nuwiki.table.insert", + "nuwiki.table.align", + "nuwiki.table.moveColumn", ]; pub(crate) async fn execute( @@ -177,6 +180,11 @@ pub(crate) async fn execute( "nuwiki.list.changeLevel" => { 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}")), } } @@ -929,6 +937,82 @@ fn list_change_level(backend: &Backend, args: Vec) -> Result) -> Result, 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) -> Result, 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) -> Result, String> { + #[derive(Deserialize)] + struct Args { + uri: Url, + position: LspPosition, + #[serde(default)] + dir: Option, + } + 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 ===== fn export_current( @@ -2149,6 +2233,264 @@ pub mod ops { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 ===== use crate::edits::op_create; diff --git a/crates/nuwiki-lsp/tests/cluster_b_table_rewriters.rs b/crates/nuwiki-lsp/tests/cluster_b_table_rewriters.rs new file mode 100644 index 0000000..99a88f2 --- /dev/null +++ b/crates/nuwiki-lsp/tests/cluster_b_table_rewriters.rs @@ -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}"); + } +} diff --git a/ftplugin/vimwiki.vim b/ftplugin/vimwiki.vim index 343a800..465c588 100644 --- a/ftplugin/vimwiki.vim +++ b/ftplugin/vimwiki.vim @@ -181,13 +181,13 @@ if !has('nvim') nnoremap whh :call nuwiki#commands#export_browse() nnoremap wha :call nuwiki#commands#export_all() - " Tables (deferred §13.1) - nnoremap gqq :echohl WarningMsgechom 'nuwiki: :VimwikiTableAlignQ not yet implemented — see SPEC §13.1'echohl None - nnoremap gq1 :echohl WarningMsgechom 'nuwiki: :VimwikiTableAlignQ1 not yet implemented — see SPEC §13.1'echohl None - nnoremap gww :echohl WarningMsgechom 'nuwiki: :VimwikiTableAlignW not yet implemented — see SPEC §13.1'echohl None - nnoremap gw1 :echohl WarningMsgechom 'nuwiki: :VimwikiTableAlignW1 not yet implemented — see SPEC §13.1'echohl None - nnoremap :echohl WarningMsgechom 'nuwiki: :VimwikiTableMoveColumnLeft not yet implemented'echohl None - nnoremap :echohl WarningMsgechom 'nuwiki: :VimwikiTableMoveColumnRight not yet implemented'echohl None + " Tables + nnoremap gqq :call nuwiki#commands#table_align() + nnoremap gq1 :call nuwiki#commands#table_align() + nnoremap gww :call nuwiki#commands#table_align() + nnoremap gw1 :call nuwiki#commands#table_align() + nnoremap :call nuwiki#commands#table_move_left() + nnoremap :call nuwiki#commands#table_move_right() endif finish diff --git a/lua/nuwiki/commands.lua b/lua/nuwiki/commands.lua index b498e3f..6949fa9 100644 --- a/lua/nuwiki/commands.lua +++ b/lua/nuwiki/commands.lua @@ -474,9 +474,33 @@ function M.list_change_lvl(direction) M.list_change_level(delta, false) end -M.table_insert = _not_yet(':VimwikiTable') -M.table_move_column_left = _not_yet(':VimwikiTableMoveColumnLeft') -M.table_move_column_right = _not_yet(':VimwikiTableMoveColumnRight') +-- §13.1 Cluster B — table rewriters. + +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') -- §13.1 Cluster C — link helpers. diff --git a/lua/nuwiki/keymaps.lua b/lua/nuwiki/keymaps.lua index cb908ae..5975ce8 100644 --- a/lua/nuwiki/keymaps.lua +++ b/lua/nuwiki/keymaps.lua @@ -255,12 +255,12 @@ function M.attach(bufnr, mappings) -- ===== Tables ===== if on('table_editing') then - map('n', 'gqq', deferred(':VimwikiTableAlignQ'), { desc = 'nuwiki: table align (deferred)' }, bufnr) - map('n', 'gq1', deferred(':VimwikiTableAlignQ1'), { desc = 'nuwiki: table align row1 (deferred)' }, bufnr) - map('n', 'gww', deferred(':VimwikiTableAlignW'), { desc = 'nuwiki: table align wide (deferred)' }, bufnr) - map('n', 'gw1', deferred(':VimwikiTableAlignW1'), { desc = 'nuwiki: table align row1 wide (deferred)' }, bufnr) - map('n', '', deferred(':VimwikiTableMoveColumnLeft'), { desc = 'nuwiki: table col left (deferred)' }, bufnr) - map('n', '', deferred(':VimwikiTableMoveColumnRight'), { desc = 'nuwiki: table col right (deferred)' }, bufnr) + map('n', 'gqq', cmd.table_align, { desc = 'nuwiki: align table' }, bufnr) + map('n', 'gq1', cmd.table_align, { desc = 'nuwiki: align table' }, bufnr) + map('n', 'gww', cmd.table_align, { desc = 'nuwiki: align table (wide)' }, bufnr) + map('n', 'gw1', cmd.table_align, { desc = 'nuwiki: align table (wide)' }, bufnr) + map('n', '', cmd.table_move_column_left, { desc = 'nuwiki: move column left' }, bufnr) + map('n', '', cmd.table_move_column_right, { desc = 'nuwiki: move column right' }, bufnr) end -- ===== HTML =====