Skip to main content

lattice_mode/modes/table/
edit.rs

1//! What the chords do to a table: move between cells, and move,
2//! insert and delete rows and columns (TB.1).
3//!
4//! Every operation here is a pure function from a [`Table`] and a caret to a
5//! new table and a new caret. Nothing touches a buffer; the mode turns the
6//! result into one `Effect::ApplyEdit` over the table's line span.
7//!
8//! **One edit, not one per row.** A column insert changes every line, and a
9//! half-applied column is a corrupt table — worse than either end state. It
10//! also means `u` undoes the operation rather than the last row of it.
11//!
12//! **The caret is tracked as a cell, not an offset.** Alignment rewrites
13//! every row, so the byte the caret sat on does not survive; what the user
14//! means by "where I was" is the cell. Re-deriving the offset from the
15//! rendered line is the only way to land in the same place after the widths
16//! move.
17
18use super::model::{Row, Table};
19
20/// A caret inside a table: which row, and which cell in it.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct Cell {
23    /// Index into [`Table::rows`].
24    pub row: usize,
25    /// Zero-based cell index within that row (the count of unescaped `|`
26    /// to the caret's left, minus the leading one).
27    pub column: usize,
28}
29
30/// Which cell contains byte offset `col` on `line`.
31///
32/// Counts the unescaped `|` to the left of the caret — the same split
33/// `layout::cells` makes, so the answer agrees with the parse. A caret before
34/// the leading pipe (in the indent) is in cell 0: it is the cell you would
35/// reach by pressing `l`, and answering `None` there would make `<Tab>` dead
36/// on a line the user is plainly inside.
37pub fn column_at(line: &str, col: usize) -> usize {
38    let mut seen = 0usize;
39    let mut escaped = false;
40    for (i, ch) in line.char_indices() {
41        if i >= col {
42            break;
43        }
44        if escaped {
45            escaped = false;
46            continue;
47        }
48        match ch {
49            '\\' => escaped = true,
50            '|' => seen += 1,
51            _ => {}
52        }
53    }
54    // The leading pipe is a boundary, not a cell — one `|` to the left still
55    // means cell 0.
56    seen.saturating_sub(1)
57}
58
59/// The byte offset of cell `column`'s first content character in `line`.
60///
61/// Where the caret goes after an operation re-renders the row. Falls back to
62/// the end of the line when the column does not exist, which is the honest
63/// answer for a ragged row: the caret lands at the row's end rather than at
64/// its start, and the user is where they were looking.
65pub fn offset_of_column(line: &str, column: usize) -> usize {
66    let mut seen = 0usize;
67    let mut escaped = false;
68    for (i, ch) in line.char_indices() {
69        if escaped {
70            escaped = false;
71            continue;
72        }
73        match ch {
74            '\\' => escaped = true,
75            '|' => {
76                if seen == column + 1 {
77                    return i;
78                }
79                seen += 1;
80                if seen == column + 1 {
81                    // Just past the opening pipe of the wanted cell: skip the
82                    // single padding space so the caret lands ON the text.
83                    let start = i + 1;
84                    return if line[start..].starts_with(' ') {
85                        start + 1
86                    } else {
87                        start
88                    };
89                }
90            }
91            _ => {}
92        }
93    }
94    line.len()
95}
96
97impl Table {
98    /// Cells in `row`, or an empty slice for a rule.
99    fn cells_at(&self, row: usize) -> &[String] {
100        match self.rows.get(row) {
101            Some(Row::Cells(c)) => c,
102            _ => &[],
103        }
104    }
105
106    /// True when `row` is a rule rather than content.
107    fn is_rule(&self, row: usize) -> bool {
108        matches!(self.rows.get(row), Some(Row::Separator { .. }))
109    }
110
111    /// The next cell after `at`, wrapping to the start of the next content
112    /// row and **skipping rules** — a rule has no cells, so stopping on one
113    /// would make `<Tab>` appear to do nothing every few presses.
114    ///
115    /// `None` at the last cell of the last row: the caller decides whether
116    /// that means "add a row" or "leave the table", and this function has no
117    /// business inventing either.
118    pub fn next_cell(&self, at: Cell) -> Option<Cell> {
119        let width = self.cells_at(at.row).len();
120        if at.column + 1 < width {
121            return Some(Cell {
122                row: at.row,
123                column: at.column + 1,
124            });
125        }
126        let mut row = at.row + 1;
127        while row < self.rows.len() {
128            if !self.is_rule(row) && !self.cells_at(row).is_empty() {
129                return Some(Cell { row, column: 0 });
130            }
131            row += 1;
132        }
133        None
134    }
135
136    /// The previous cell, wrapping to the END of the previous content row.
137    pub fn prev_cell(&self, at: Cell) -> Option<Cell> {
138        if at.column > 0 {
139            return Some(Cell {
140                row: at.row,
141                column: at.column - 1,
142            });
143        }
144        let mut row = at.row;
145        while row > 0 {
146            row -= 1;
147            if !self.is_rule(row) {
148                let width = self.cells_at(row).len();
149                if width > 0 {
150                    return Some(Cell {
151                        row,
152                        column: width - 1,
153                    });
154                }
155            }
156        }
157        None
158    }
159
160    /// Swap the row at `at.row` with the one `delta` away, skipping rules.
161    ///
162    /// Rules are skipped rather than swapped through: a rule marks a boundary
163    /// (a header, a section), and dragging a content row across one changes
164    /// what the table means, where swapping with the row beyond it does what
165    /// the user pictured.
166    pub fn move_row(&self, at: Cell, delta: isize) -> Option<(Table, Cell)> {
167        if self.is_rule(at.row) {
168            return None;
169        }
170        let mut target = at.row as isize;
171        loop {
172            target += delta;
173            if target < 0 || target as usize >= self.rows.len() {
174                return None;
175            }
176            if !self.is_rule(target as usize) {
177                break;
178            }
179        }
180        let target = target as usize;
181        let mut next = self.clone();
182        next.rows.swap(at.row, target);
183        Some((
184            next,
185            Cell {
186                row: target,
187                column: at.column,
188            },
189        ))
190    }
191
192    /// Swap column `at.column` with the one `delta` away, in every row.
193    pub fn move_column(&self, at: Cell, delta: isize) -> Option<(Table, Cell)> {
194        let columns = self.columns();
195        let target = at.column as isize + delta;
196        if target < 0 || target as usize >= columns {
197            return None;
198        }
199        let target = target as usize;
200        let mut next = self.clone();
201        for row in &mut next.rows {
202            match row {
203                Row::Cells(cells) => {
204                    // Pad first: a ragged row cannot swap a column it does
205                    // not have, and refusing the whole operation because ONE
206                    // row is short would fail exactly mid-edit.
207                    if cells.len() < columns {
208                        cells.resize(columns, String::new());
209                    }
210                    cells.swap(at.column, target);
211                }
212                Row::Separator { aligns, .. } => {
213                    if aligns.len() >= columns.max(1) && target < aligns.len() {
214                        aligns.swap(at.column, target);
215                    }
216                }
217            }
218        }
219        Some((
220            next,
221            Cell {
222                row: at.row,
223                column: target,
224            },
225        ))
226    }
227
228    /// Insert an empty row below `at.row`, and put the caret in it.
229    ///
230    /// Below rather than above, matching `o` — the row you want is almost
231    /// always the next one, and `O`'s peer is a follow-up chord rather than a
232    /// guess about which one you meant.
233    pub fn insert_row(&self, at: Cell) -> (Table, Cell) {
234        let columns = self.columns().max(1);
235        let mut next = self.clone();
236        let row = (at.row + 1).min(next.rows.len());
237        next.rows
238            .insert(row, Row::Cells(vec![String::new(); columns]));
239        (next, Cell { row, column: 0 })
240    }
241
242    /// Insert an empty column to the right of `at.column`, in every row.
243    pub fn insert_column(&self, at: Cell) -> (Table, Cell) {
244        let columns = self.columns();
245        let target = (at.column + 1).min(columns);
246        let mut next = self.clone();
247        for row in &mut next.rows {
248            match row {
249                Row::Cells(cells) => {
250                    if cells.len() < columns {
251                        cells.resize(columns, String::new());
252                    }
253                    cells.insert(target.min(cells.len()), String::new());
254                }
255                Row::Separator { aligns, .. } => {
256                    if target <= aligns.len() {
257                        aligns.insert(target, super::layout::Align::Left);
258                    }
259                }
260            }
261        }
262        (
263            next,
264            Cell {
265                row: at.row,
266                column: target,
267            },
268        )
269    }
270
271    /// Delete the row under the caret.
272    ///
273    /// `None` when it is the table's last content row: deleting it would
274    /// leave a rule floating with nothing to rule, and the user asked to
275    /// delete a row, not the table. `dd` is right there for that.
276    pub fn delete_row(&self, at: Cell) -> Option<(Table, Cell)> {
277        let content = self
278            .rows
279            .iter()
280            .filter(|r| matches!(r, Row::Cells(_)))
281            .count();
282        if content <= 1 && !self.is_rule(at.row) {
283            return None;
284        }
285        if at.row >= self.rows.len() {
286            return None;
287        }
288        let mut next = self.clone();
289        next.rows.remove(at.row);
290        let row = at.row.min(next.rows.len().saturating_sub(1));
291        Some((
292            next,
293            Cell {
294                row,
295                column: at.column,
296            },
297        ))
298    }
299
300    /// Delete the column under the caret, in every row.
301    ///
302    /// `None` on the last column, for [`Self::delete_row`]'s reason: a table
303    /// with no columns is not a table, it is a stack of `|`.
304    pub fn delete_column(&self, at: Cell) -> Option<(Table, Cell)> {
305        let columns = self.columns();
306        if columns <= 1 || at.column >= columns {
307            return None;
308        }
309        let mut next = self.clone();
310        for row in &mut next.rows {
311            match row {
312                Row::Cells(cells) => {
313                    if at.column < cells.len() {
314                        cells.remove(at.column);
315                    }
316                }
317                Row::Separator { aligns, .. } => {
318                    if at.column < aligns.len() {
319                        aligns.remove(at.column);
320                    }
321                }
322            }
323        }
324        Some((
325            next,
326            Cell {
327                row: at.row,
328                column: at.column.min(columns - 2),
329            },
330        ))
331    }
332
333    // ── TB.3 ────────────────────────────────────────────────────────────
334
335    /// Insert a horizontal rule below `at.row`.
336    ///
337    /// **The style is copied from a rule the table already has.** Only a table
338    /// with none needs `fallback`, which is the single place the dialect is
339    /// not readable off the buffer — see `mode::rule_fallback`.
340    pub fn insert_rule(&self, at: Cell, fallback: char) -> (Table, Cell) {
341        let join = self.rule_join().unwrap_or(fallback);
342        let aligns = self.alignments_or_default();
343        let mut next = self.clone();
344        let row = (at.row + 1).min(next.rows.len());
345        next.rows.insert(row, Row::Separator { join, aligns });
346        // The caret does NOT stay on the rule: there is nothing to type in
347        // one, and leaving it there means the next `<Tab>` is the user's
348        // first hint that they are parked on a line with no cells.
349        let landing = if row + 1 < next.rows.len() {
350            row + 1
351        } else {
352            at.row
353        };
354        (
355            next,
356            Cell {
357                row: landing,
358                column: at.column,
359            },
360        )
361    }
362
363    /// The join character of the table's first rule, if it has one.
364    fn rule_join(&self) -> Option<char> {
365        self.rows.iter().find_map(|r| match r {
366            Row::Separator { join, .. } => Some(*join),
367            Row::Cells(_) => None,
368        })
369    }
370
371    fn alignments_or_default(&self) -> Vec<super::layout::Align> {
372        self.rows
373            .iter()
374            .find_map(|r| match r {
375                Row::Separator { aligns, .. } => Some(aligns.clone()),
376                Row::Cells(_) => None,
377            })
378            .unwrap_or_else(|| vec![super::layout::Align::Left; self.columns()])
379    }
380
381    /// The run of content rows `at.row` belongs to — the rows between the
382    /// rules either side of it, as an inclusive `(first, last)`.
383    ///
384    /// Sorting is per SECTION rather than whole-table, and that is the whole
385    /// reason this exists: a rule separates a header from a body (or one
386    /// group from the next), and a sort that crossed it would drag the header
387    /// row into the middle of the data. Emacs sorts the region between rules
388    /// for the same reason.
389    fn section(&self, at: usize) -> Option<(usize, usize)> {
390        if self.is_rule(at) {
391            return None;
392        }
393        let mut first = at;
394        while first > 0 && !self.is_rule(first - 1) {
395            first -= 1;
396        }
397        let mut last = at;
398        while last + 1 < self.rows.len() && !self.is_rule(last + 1) {
399            last += 1;
400        }
401        Some((first, last))
402    }
403
404    /// Sort the caret's section by the column under the caret.
405    ///
406    /// **The comparator is chosen from the data**, not asked for: numeric when
407    /// every non-empty value in the column parses as a number, case-insensitive
408    /// lexicographic otherwise. Emacs prompts for `a`/`n`/`t`; a prompt on a
409    /// single chord is a question with an obvious answer in nearly every real
410    /// table, and getting it wrong is one undo.
411    ///
412    /// Empty cells sort LAST in both directions. An empty cell is the absence
413    /// of a value rather than a small one, so floating it to the top of a
414    /// descending sort would bury the rows you asked to see.
415    pub fn sort_section(&self, at: Cell, descending: bool) -> Option<(Table, Cell)> {
416        let (first, last) = self.section(at.row)?;
417        if last <= first {
418            return None;
419        }
420        let value = |row: usize| -> String {
421            self.cells_at(row)
422                .get(at.column)
423                .cloned()
424                .unwrap_or_default()
425        };
426        let numeric = (first..=last)
427            .map(value)
428            .filter(|v| !v.trim().is_empty())
429            .all(|v| v.trim().parse::<f64>().is_ok());
430
431        let mut order: Vec<usize> = (first..=last).collect();
432        order.sort_by(|&a, &b| {
433            let (va, vb) = (value(a), value(b));
434            let (ea, eb) = (va.trim().is_empty(), vb.trim().is_empty());
435            // Empties last, before the direction is applied — so they stay
436            // last when it flips.
437            match (ea, eb) {
438                (true, true) => return std::cmp::Ordering::Equal,
439                (true, false) => return std::cmp::Ordering::Greater,
440                (false, true) => return std::cmp::Ordering::Less,
441                (false, false) => {}
442            }
443            let ord = if numeric {
444                let na = va.trim().parse::<f64>().unwrap_or(0.0);
445                let nb = vb.trim().parse::<f64>().unwrap_or(0.0);
446                na.partial_cmp(&nb).unwrap_or(std::cmp::Ordering::Equal)
447            } else {
448                va.to_lowercase().cmp(&vb.to_lowercase())
449            };
450            if descending { ord.reverse() } else { ord }
451        });
452
453        let mut next = self.clone();
454        let sorted: Vec<Row> = order.iter().map(|&i| self.rows[i].clone()).collect();
455        next.rows.splice(first..=last, sorted);
456        // The caret follows the ROW it was on, not the position — you sorted
457        // to see where your row went.
458        let landed = order
459            .iter()
460            .position(|&i| i == at.row)
461            .map(|p| first + p)
462            .unwrap_or(at.row);
463        Some((
464            next,
465            Cell {
466                row: landed,
467                column: at.column,
468            },
469        ))
470    }
471
472    /// Empty the cell under the caret, leaving the table's shape alone.
473    pub fn blank_cell(&self, at: Cell) -> Option<(Table, Cell)> {
474        let mut next = self.clone();
475        match next.rows.get_mut(at.row)? {
476            Row::Cells(cells) => {
477                let slot = cells.get_mut(at.column)?;
478                if slot.is_empty() {
479                    return None;
480                }
481                slot.clear();
482            }
483            Row::Separator { .. } => return None,
484        }
485        Some((next, at))
486    }
487
488    /// Copy the caret's field into the row below, and move down with it.
489    ///
490    /// Emacs' `org-table-copy-down` (`S-<CR>`), including the increment: a
491    /// value ending in an integer copies down as that integer plus one, which
492    /// is what makes it a *series* filler rather than a duplicator. `Q3` →
493    /// `Q4`, `1.2` → `1.3` — the trailing run of digits moves, the rest is
494    /// carried verbatim.
495    ///
496    /// Creates the row below when the caret is on the last one, since
497    /// stopping at the bottom would make the chord fail exactly when you are
498    /// filling a column downwards.
499    pub fn copy_down(&self, at: Cell) -> Option<(Table, Cell)> {
500        let source = self.cells_at(at.row).get(at.column)?.clone();
501        let mut next = self.clone();
502        let mut target = at.row + 1;
503        // Skip a rule rather than writing into it, and create a row when
504        // there is nothing below.
505        while target < next.rows.len() && next.is_rule(target) {
506            target += 1;
507        }
508        if target >= next.rows.len() {
509            let columns = next.columns().max(at.column + 1);
510            next.rows.push(Row::Cells(vec![String::new(); columns]));
511            target = next.rows.len() - 1;
512        }
513        let columns = next.columns().max(at.column + 1);
514        match next.rows.get_mut(target)? {
515            Row::Cells(cells) => {
516                if cells.len() < columns {
517                    cells.resize(columns, String::new());
518                }
519                cells[at.column] = increment_trailing_number(&source);
520            }
521            Row::Separator { .. } => return None,
522        }
523        Some((
524            next,
525            Cell {
526                row: target,
527                column: at.column,
528            },
529        ))
530    }
531
532    /// The cell directly below `at`, skipping rules — where Insert-mode
533    /// `<CR>` goes.
534    ///
535    /// Distinct from [`Self::next_cell`], which wraps to the start of the next
536    /// row: `<CR>` keeps the COLUMN, because it is how you fill one downwards.
537    /// `None` at the bottom; the caller decides whether that means a new row.
538    pub fn down_cell(&self, at: Cell) -> Option<Cell> {
539        let mut row = at.row + 1;
540        while row < self.rows.len() {
541            if !self.is_rule(row) {
542                return Some(Cell {
543                    row,
544                    column: at.column,
545                });
546            }
547            row += 1;
548        }
549        None
550    }
551
552    /// Swap the table's rows and columns.
553    ///
554    /// **Rules do not survive**, and cannot: a rule separates row groups, and
555    /// after a transpose those groups are columns — there is no horizontal
556    /// line that means what it meant. Emacs' `org-table-transpose-table-at-point`
557    /// drops them for the same reason. Dropping them is stated here rather
558    /// than discovered in a diff.
559    pub fn transpose(&self, at: Cell) -> Option<(Table, Cell)> {
560        let content: Vec<&[String]> = self
561            .rows
562            .iter()
563            .filter_map(|r| match r {
564                Row::Cells(c) => Some(c.as_slice()),
565                Row::Separator { .. } => None,
566            })
567            .collect();
568        if content.is_empty() {
569            return None;
570        }
571        let columns = content.iter().map(|r| r.len()).max().unwrap_or(0);
572        if columns == 0 {
573            return None;
574        }
575        let rows: Vec<Row> = (0..columns)
576            .map(|c| {
577                Row::Cells(
578                    content
579                        .iter()
580                        .map(|r| r.get(c).cloned().unwrap_or_default())
581                        .collect(),
582                )
583            })
584            .collect();
585        // The caret's cell transposes with the table: the value that was at
586        // (row, column) is now at (column, row), and landing anywhere else
587        // would lose the user's place in a table that just changed shape.
588        let content_index = self.rows[..at.row.min(self.rows.len())]
589            .iter()
590            .filter(|r| matches!(r, Row::Cells(_)))
591            .count();
592        Some((
593            Table {
594                first: self.first,
595                last: self.last,
596                rows,
597                indent: self.indent.clone(),
598            },
599            Cell {
600                row: at.column.min(columns.saturating_sub(1)),
601                column: content_index,
602            },
603        ))
604    }
605}
606
607/// `Q3` → `Q4`, `1.2` → `1.3`, `total` → `total`.
608///
609/// Only the trailing run of digits moves; everything before it is carried
610/// verbatim, which is what makes this work on `2026-09` and `item-9` alike.
611/// A value with no trailing digits copies unchanged — the honest answer for
612/// a label, and the common case for a header being filled down.
613fn increment_trailing_number(value: &str) -> String {
614    let digits_start = value
615        .char_indices()
616        .rev()
617        .take_while(|(_, c)| c.is_ascii_digit())
618        .map(|(i, _)| i)
619        .last();
620    let Some(start) = digits_start else {
621        return value.to_string();
622    };
623    let Ok(n) = value[start..].parse::<u64>() else {
624        return value.to_string();
625    };
626    // Preserved width, so `09` becomes `10` and not `1O`-looking `10` beside
627    // a column of `07`, `08`.
628    let width = value.len() - start;
629    format!("{}{:0width$}", &value[..start], n + 1, width = width)
630}
631
632#[cfg(test)]
633mod tests {
634    #![allow(clippy::unwrap_used, clippy::panic)]
635    use super::*;
636
637    fn table(src: &str) -> Table {
638        let lines: Vec<String> = src.lines().map(str::to_string).collect();
639        let n = lines.len() as u32;
640        Table::at(|i| lines.get(i as usize).cloned(), 0, n).expect("a table")
641    }
642
643    /// `| a | b |` — three rows, the middle one a rule.
644    fn ruled() -> Table {
645        table("| a | b |\n|---|---|\n| c | d |\n")
646    }
647
648    fn shape(t: &Table) -> Vec<String> {
649        t.render()
650    }
651
652    // ── where the caret is ──────────────────────────────────────────────
653
654    #[test]
655    fn the_caret_column_counts_pipes_to_its_left() {
656        let line = "| a | b | c |";
657        assert_eq!(column_at(line, 2), 0);
658        assert_eq!(column_at(line, 6), 1);
659        assert_eq!(column_at(line, 10), 2);
660    }
661
662    /// An escaped pipe is content, not a boundary. Counting it would put the
663    /// caret one cell to the right of where it looks.
664    #[test]
665    fn an_escaped_pipe_is_not_a_cell_boundary() {
666        let line = r"| a \| b | c |";
667        assert_eq!(column_at(line, 8), 0, "still inside the first cell");
668    }
669
670    /// A caret in the indent is in cell 0 — answering "nowhere" would make
671    /// `<Tab>` dead on a line the user is plainly inside.
672    #[test]
673    fn a_caret_before_the_first_pipe_is_in_cell_zero() {
674        assert_eq!(column_at("  | a | b |", 0), 0);
675    }
676
677    /// The round trip that matters: after a re-render the mode asks for the
678    /// offset of the cell it was in, and must land back on the same text.
679    #[test]
680    fn the_offset_of_a_column_lands_on_its_text() {
681        let line = "| aa | bb | cc |";
682        for c in 0..3 {
683            let at = offset_of_column(line, c);
684            assert_eq!(
685                &line[at..at + 2],
686                ["aa", "bb", "cc"][c],
687                "column {c} in {line:?}"
688            );
689        }
690    }
691
692    // ── moving between cells ────────────────────────────────────────────
693
694    #[test]
695    fn tab_walks_the_row_then_wraps_to_the_next() {
696        let t = ruled();
697        let at = Cell { row: 0, column: 0 };
698        let next = t.next_cell(at).unwrap();
699        assert_eq!(next, Cell { row: 0, column: 1 });
700        // …and from the last cell of row 0 it must land on row 2, NOT the
701        // rule at row 1, which has no cells to sit in.
702        assert_eq!(t.next_cell(next).unwrap(), Cell { row: 2, column: 0 });
703    }
704
705    #[test]
706    fn shift_tab_walks_backwards_and_wraps_to_the_end_of_the_row_above() {
707        let t = ruled();
708        let at = Cell { row: 2, column: 0 };
709        assert_eq!(t.prev_cell(at).unwrap(), Cell { row: 0, column: 1 });
710    }
711
712    /// The last cell of the last row has no next. Returning `Some` of
713    /// something invented — a new row, the first cell again — is a decision
714    /// for the caller, and burying it here would make `<Tab>` mean two
715    /// different things depending on data.
716    #[test]
717    fn the_last_cell_has_no_next() {
718        let t = ruled();
719        assert!(t.next_cell(Cell { row: 2, column: 1 }).is_none());
720        assert!(t.prev_cell(Cell { row: 0, column: 0 }).is_none());
721    }
722
723    // ── rows ────────────────────────────────────────────────────────────
724
725    #[test]
726    fn a_row_moves_and_the_caret_follows_it() {
727        let t = table("| a |\n| b |\n");
728        let (moved, cell) = t.move_row(Cell { row: 0, column: 0 }, 1).unwrap();
729        assert_eq!(cell.row, 1, "the caret rides the row it moved");
730        assert!(shape(&moved)[0].contains('b'));
731        assert!(shape(&moved)[1].contains('a'));
732    }
733
734    /// A rule marks a boundary. Dragging a content row across one changes
735    /// what the table means; swapping with the row BEYOND it is what the
736    /// user pictured.
737    #[test]
738    fn moving_a_row_skips_over_a_rule() {
739        let t = ruled();
740        let (moved, cell) = t.move_row(Cell { row: 0, column: 0 }, 1).unwrap();
741        assert_eq!(cell.row, 2);
742        assert!(
743            matches!(moved.rows[1], Row::Separator { .. }),
744            "the rule stayed put: {:?}",
745            moved.rows
746        );
747    }
748
749    #[test]
750    fn a_row_at_the_edge_does_not_move() {
751        let t = table("| a |\n| b |\n");
752        assert!(t.move_row(Cell { row: 0, column: 0 }, -1).is_none());
753        assert!(t.move_row(Cell { row: 1, column: 0 }, 1).is_none());
754    }
755
756    #[test]
757    fn inserting_a_row_puts_it_below_and_takes_the_caret_there() {
758        let t = ruled();
759        let (next, cell) = t.insert_row(Cell { row: 2, column: 1 });
760        assert_eq!(next.rows.len(), 4);
761        assert_eq!(cell, Cell { row: 3, column: 0 });
762        assert_eq!(next.columns(), 2, "the new row is full width");
763    }
764
765    #[test]
766    fn deleting_a_row_removes_it() {
767        let t = ruled();
768        let (next, _) = t.delete_row(Cell { row: 2, column: 0 }).unwrap();
769        assert_eq!(next.rows.len(), 2);
770    }
771
772    /// The last content row is not deletable: what would be left is a rule
773    /// ruling nothing. `dd` is right there for deleting the table.
774    #[test]
775    fn the_last_content_row_is_kept() {
776        let t = table("| a |\n");
777        assert!(t.delete_row(Cell { row: 0, column: 0 }).is_none());
778    }
779
780    // ── columns ─────────────────────────────────────────────────────────
781
782    #[test]
783    fn a_column_moves_in_every_row_at_once() {
784        let t = ruled();
785        let (moved, cell) = t.move_column(Cell { row: 0, column: 0 }, 1).unwrap();
786        assert_eq!(cell.column, 1);
787        let out = shape(&moved);
788        assert!(out[0].starts_with("| b"), "{out:?}");
789        assert!(out[2].starts_with("| d"), "…and the row below too: {out:?}");
790    }
791
792    /// A ragged row is padded rather than aborting the whole operation —
793    /// mid-edit is exactly when a row is short and exactly when the key is
794    /// wanted.
795    #[test]
796    fn moving_a_column_pads_a_short_row_instead_of_refusing() {
797        let t = table("| a | b |\n| c |\n");
798        let (moved, _) = t.move_column(Cell { row: 0, column: 0 }, 1).unwrap();
799        let out = shape(&moved);
800        assert_eq!(out.len(), 2);
801        assert!(
802            out[1].contains('c'),
803            "the short row kept its content: {out:?}"
804        );
805    }
806
807    #[test]
808    fn a_column_at_the_edge_does_not_move() {
809        let t = ruled();
810        assert!(t.move_column(Cell { row: 0, column: 0 }, -1).is_none());
811        assert!(t.move_column(Cell { row: 0, column: 1 }, 1).is_none());
812    }
813
814    #[test]
815    fn inserting_a_column_widens_every_row_including_the_rule() {
816        let t = ruled();
817        let (next, cell) = t.insert_column(Cell { row: 0, column: 0 });
818        assert_eq!(cell.column, 1);
819        assert_eq!(next.columns(), 3);
820        let rule = &shape(&next)[1];
821        assert_eq!(
822            rule.matches('|').count(),
823            4,
824            "the rule grew a column too: {rule}"
825        );
826    }
827
828    #[test]
829    fn deleting_a_column_narrows_every_row() {
830        let t = ruled();
831        let (next, _) = t.delete_column(Cell { row: 0, column: 0 }).unwrap();
832        assert_eq!(next.columns(), 1);
833        assert!(shape(&next)[0].contains('b'));
834    }
835
836    /// A table with no columns is not a table, it is a stack of pipes.
837    #[test]
838    fn the_last_column_is_kept() {
839        let t = table("| a |\n|---|\n");
840        assert!(t.delete_column(Cell { row: 0, column: 0 }).is_none());
841    }
842
843    // ── TB.3 ────────────────────────────────────────────────────────────
844
845    /// A new rule copies the style of one the table already has, so
846    /// inserting into an org table does not quietly plant a markdown rule
847    /// that the next align then preserves forever.
848    #[test]
849    fn a_new_rule_copies_the_style_the_table_already_uses() {
850        let t = table("| a | b |\n|--+--|\n| c | d |\n");
851        let (next, _) = t.insert_rule(Cell { row: 2, column: 0 }, '|');
852        let out = shape(&next);
853        let rules: Vec<&String> = out.iter().filter(|l| l.contains('-')).collect();
854        assert_eq!(rules.len(), 2);
855        assert!(
856            rules.iter().all(|l| l.contains('+')),
857            "both rules are org's: {rules:?}"
858        );
859    }
860
861    /// The fallback is used ONLY when there is no rule to copy — the single
862    /// case the buffer cannot answer.
863    #[test]
864    fn the_fallback_style_is_used_only_when_there_is_no_rule() {
865        let t = table("| a | b |\n| c | d |\n");
866        let (org, _) = t.insert_rule(Cell { row: 0, column: 0 }, '+');
867        assert!(shape(&org)[1].contains('+'), "{:?}", shape(&org));
868        let (md, _) = t.insert_rule(Cell { row: 0, column: 0 }, '|');
869        assert!(!shape(&md)[1].contains('+'), "{:?}", shape(&md));
870    }
871
872    /// The caret does not park on the rule. There is nothing to type in one,
873    /// and the next `<Tab>` would be the user's first hint that they were on
874    /// a line with no cells.
875    #[test]
876    fn the_caret_moves_off_the_new_rule() {
877        let t = table("| a |\n| b |\n");
878        let (next, cell) = t.insert_rule(Cell { row: 0, column: 0 }, '|');
879        assert!(!next.is_rule(cell.row), "landed on a rule: {:?}", next.rows);
880    }
881
882    // ── sorting ─────────────────────────────────────────────────────────
883
884    /// Numeric when the column is numbers — `10` sorts after `9`, which
885    /// lexicographic order gets backwards and which is the single most
886    /// noticeable way a sort can be wrong.
887    #[test]
888    fn a_numeric_column_sorts_numerically() {
889        let t = table("| 9 |\n| 10 |\n| 2 |\n");
890        let (next, _) = t.sort_section(Cell { row: 0, column: 0 }, false).unwrap();
891        let got: Vec<String> = shape(&next)
892            .iter()
893            .map(|l| l.trim_matches(['|', ' ']).to_string())
894            .collect();
895        assert_eq!(got, vec!["2", "9", "10"]);
896    }
897
898    #[test]
899    fn a_text_column_sorts_case_insensitively() {
900        let t = table("| beta |\n| Alpha |\n| gamma |\n");
901        let (next, _) = t.sort_section(Cell { row: 0, column: 0 }, false).unwrap();
902        let got: Vec<String> = shape(&next)
903            .iter()
904            .map(|l| l.trim_matches(['|', ' ']).to_string())
905            .collect();
906        assert_eq!(got, vec!["Alpha", "beta", "gamma"]);
907    }
908
909    /// A sort must NOT cross a rule. The rule separates the header from the
910    /// body, and a sort that dragged the header into the middle of the data
911    /// would be wrong in the way that costs a `u` and a double-take.
912    #[test]
913    fn sorting_stays_inside_its_section() {
914        let t = table("| header |\n|---|\n| b |\n| a |\n");
915        let (next, _) = t.sort_section(Cell { row: 2, column: 0 }, false).unwrap();
916        let out = shape(&next);
917        assert!(out[0].contains("header"), "the header stayed put: {out:?}");
918        assert!(out[2].contains('a') && out[3].contains('b'), "{out:?}");
919    }
920
921    /// Empty cells sort last in BOTH directions: an empty cell is the absence
922    /// of a value, not a small one, so floating it to the top of a descending
923    /// sort would bury the rows you asked to see.
924    #[test]
925    fn empty_cells_sort_last_whichever_way_you_sort() {
926        let t = table("| b |\n|  |\n| a |\n");
927        for descending in [false, true] {
928            let (next, _) = t
929                .sort_section(Cell { row: 0, column: 0 }, descending)
930                .unwrap();
931            let out = shape(&next);
932            assert!(
933                out[2].trim_matches(['|', ' ']).is_empty(),
934                "descending={descending}: {out:?}"
935            );
936        }
937    }
938
939    /// The caret follows its ROW. You sorted to see where your row went.
940    #[test]
941    fn the_caret_follows_the_row_it_was_on() {
942        let t = table("| 3 |\n| 1 |\n| 2 |\n");
943        let (_, cell) = t.sort_section(Cell { row: 0, column: 0 }, false).unwrap();
944        assert_eq!(cell.row, 2, "the `3` row is last now, and so is the caret");
945    }
946
947    #[test]
948    fn a_one_row_section_has_nothing_to_sort() {
949        let t = table("| a |\n");
950        assert!(t.sort_section(Cell { row: 0, column: 0 }, false).is_none());
951    }
952
953    // ── blank / copy-down / transpose ───────────────────────────────────
954
955    #[test]
956    fn blanking_empties_the_cell_and_leaves_the_shape() {
957        let t = table("| a | b |\n");
958        let (next, _) = t.blank_cell(Cell { row: 0, column: 0 }).unwrap();
959        assert_eq!(next.columns(), 2, "still two columns");
960        assert!(shape(&next)[0].contains('b'));
961        assert!(!shape(&next)[0].contains('a'));
962        // An already-empty cell is a no-op rather than an edit that changes
963        // nothing — an undo step for no change is a papercut.
964        assert!(next.blank_cell(Cell { row: 0, column: 0 }).is_none());
965    }
966
967    /// Emacs' `org-table-copy-down`: the trailing integer moves, which is
968    /// what makes it a series filler rather than a duplicator.
969    #[test]
970    fn copy_down_increments_a_trailing_number() {
971        let t = table("| Q3 | x |\n| | |\n");
972        let (next, cell) = t.copy_down(Cell { row: 0, column: 0 }).unwrap();
973        assert_eq!(cell.row, 1, "the caret moved down with it");
974        assert!(shape(&next)[1].contains("Q4"), "{:?}", shape(&next));
975    }
976
977    #[test]
978    fn copy_down_carries_a_plain_label_unchanged() {
979        let t = table("| total |\n| |\n");
980        let (next, _) = t.copy_down(Cell { row: 0, column: 0 }).unwrap();
981        assert!(shape(&next)[1].contains("total"), "{:?}", shape(&next));
982    }
983
984    /// Filling a column downwards must not fail at the bottom, which is
985    /// exactly where you are when you are filling one.
986    #[test]
987    fn copy_down_creates_the_row_it_needs() {
988        let t = table("| 1 |\n");
989        let (next, cell) = t.copy_down(Cell { row: 0, column: 0 }).unwrap();
990        assert_eq!(next.rows.len(), 2);
991        assert_eq!(cell.row, 1);
992        assert!(shape(&next)[1].contains('2'));
993    }
994
995    /// A leading zero keeps its width — `09` becomes `10`, not `1`, so a
996    /// column of `07`, `08`, `09` does not lose its alignment at the tens.
997    #[test]
998    fn an_incremented_number_keeps_its_width() {
999        assert_eq!(increment_trailing_number("09"), "10");
1000        assert_eq!(increment_trailing_number("item-9"), "item-10");
1001        assert_eq!(increment_trailing_number("total"), "total");
1002        assert_eq!(increment_trailing_number(""), "");
1003    }
1004
1005    /// `<CR>` keeps the COLUMN — it is how you fill one downwards — where
1006    /// `<Tab>` wraps to the start of the next row.
1007    #[test]
1008    fn down_cell_keeps_the_column_and_skips_a_rule() {
1009        let t = ruled();
1010        assert_eq!(
1011            t.down_cell(Cell { row: 0, column: 1 }).unwrap(),
1012            Cell { row: 2, column: 1 }
1013        );
1014        assert!(t.down_cell(Cell { row: 2, column: 1 }).is_none());
1015    }
1016
1017    #[test]
1018    fn transpose_swaps_rows_and_columns() {
1019        let t = table("| a | b | c |\n| 1 | 2 | 3 |\n");
1020        let (next, _) = t.transpose(Cell { row: 0, column: 0 }).unwrap();
1021        let out = shape(&next);
1022        assert_eq!(out.len(), 3, "three rows now: {out:?}");
1023        assert!(out[0].contains('a') && out[0].contains('1'), "{out:?}");
1024        assert!(out[2].contains('c') && out[2].contains('3'), "{out:?}");
1025    }
1026
1027    /// The caret transposes with its cell — landing anywhere else loses the
1028    /// user's place in a table that just changed shape.
1029    #[test]
1030    fn the_caret_transposes_too() {
1031        let t = table("| a | b | c |\n| 1 | 2 | 3 |\n");
1032        let (_, cell) = t.transpose(Cell { row: 1, column: 2 }).unwrap();
1033        assert_eq!(cell, Cell { row: 2, column: 1 }, "(1,2) becomes (2,1)");
1034    }
1035
1036    /// Rules cannot survive a transpose: a rule separates row groups, and
1037    /// after the swap those groups are columns. Emacs drops them for the same
1038    /// reason; stating it here is what keeps it from reading as a bug.
1039    #[test]
1040    fn transpose_drops_the_rules() {
1041        let t = table("| a | b |\n|---|---|\n| 1 | 2 |\n");
1042        let (next, _) = t.transpose(Cell { row: 0, column: 0 }).unwrap();
1043        assert!(
1044            !next.rows.iter().any(|r| matches!(r, Row::Separator { .. })),
1045            "{:?}",
1046            next.rows
1047        );
1048    }
1049
1050    /// Every structural operation goes through the same dialect-preserving
1051    /// renderer, so org's rule survives a column insert exactly as it
1052    /// survives an align.
1053    #[test]
1054    fn structure_operations_preserve_the_dialect() {
1055        let t = table("| a | b |\n|--+--|\n| c | d |\n");
1056        let (next, _) = t.insert_column(Cell { row: 0, column: 0 });
1057        assert!(
1058            shape(&next)[1].contains('+'),
1059            "org's join survives a column insert: {:?}",
1060            shape(&next)
1061        );
1062    }
1063}