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:
@@ -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')
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 =====
|
||||||
|
|||||||
Reference in New Issue
Block a user