use edamame::config::{Action, Theme};
use edamame::document::Buffer;
use edamame::editor::{edit_ops, EditorState, Mode};
use edamame::ui::table_view;
use ratatui::layout::Rect;
const VP: usize = 40;
const VW: usize = 80;
fn theme() -> &'static Theme {
Box::leak(Box::new(Theme::default()))
}
fn apply(st: &mut EditorState, action: Action) -> bool {
edit_ops::apply(st, action, VP, VW)
}
fn apply_all(st: &mut EditorState, actions: &[Action]) {
for a in actions {
apply(st, a.clone());
}
}
fn editor_at(text: &str, needle: &str) -> EditorState {
let mut st = EditorState::new(Buffer::from_str(text), theme());
st.mode = Mode::Rendered;
let byte = text.find(needle).expect("needle not found");
let char_off = st.buffer.rope().byte_to_char(byte);
st.cursor.offset = char_off;
st.update_cursor_block();
st
}
#[test]
fn editor_at_places_cursor_on_needle() {
let src = "| a | b |\n|---|---|\n| 1 | 22 |\n";
let st = editor_at(src, "22");
let byte = st.buffer.rope().char_to_byte(st.cursor.offset);
assert_eq!(&src[byte..byte + 2], "22");
}
#[test]
fn tab_advances_to_next_cell_within_row() {
let src = "| a | b | c |\n|---|---|---|\n| 11 | 22 | 33 |\n";
let mut st = editor_at(src, "11");
apply(&mut st, Action::InsertTab);
let byte = st.buffer.rope().char_to_byte(st.cursor.offset);
let contents = st.contents();
assert_eq!(&contents[byte - 2..byte], "22");
assert_eq!(&contents[byte..byte + 1], " ");
}
#[test]
fn tab_at_end_of_row_wraps_to_next_row() {
let src = "| a | b |\n|---|---|\n| 11 | 22 |\n| 33 | 44 |\n";
let mut st = editor_at(src, "22");
apply(&mut st, Action::InsertTab);
let byte = st.buffer.rope().char_to_byte(st.cursor.offset);
let contents = st.contents();
assert_eq!(&contents[byte - 2..byte], "33");
assert_eq!(&contents[byte..byte + 1], " ");
}
#[test]
fn tab_at_end_of_last_row_appends_new_row() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut st = editor_at(src, "2");
apply(&mut st, Action::InsertTab);
let contents = st.contents();
assert!(
contents.lines().filter(|l| l.starts_with('|')).count() >= 4,
"expected a 4th table line after Tab-creates-row; got:\n{contents}"
);
assert!(contents.contains("| | |"));
}
#[test]
fn shift_tab_retreats_to_previous_cell() {
let src = "| a | b |\n|---|---|\n| 11 | 22 |\n";
let mut st = editor_at(src, "22");
apply(&mut st, Action::TablePrevCell);
let byte = st.buffer.rope().char_to_byte(st.cursor.offset);
let contents = st.contents();
assert_eq!(&contents[byte - 2..byte], "11");
assert_eq!(&contents[byte..byte + 1], " ");
}
#[test]
fn shift_tab_at_start_of_row_wraps_to_previous_row() {
let src = "| a | b |\n|---|---|\n| 11 | 22 |\n| 33 | 44 |\n";
let mut st = editor_at(src, "33");
apply(&mut st, Action::TablePrevCell);
let byte = st.buffer.rope().char_to_byte(st.cursor.offset);
let contents = st.contents();
assert_eq!(&contents[byte - 2..byte], "22");
assert_eq!(&contents[byte..byte + 1], " ");
}
#[test]
fn enter_inside_table_moves_down_a_row_preserving_column() {
let src = "| a | b |\n|---|---|\n| 11 | 22 |\n| 33 | 44 |\n";
let mut st = editor_at(src, "22");
apply(&mut st, Action::Newline);
let byte = st.buffer.rope().char_to_byte(st.cursor.offset);
let contents = st.contents();
assert_eq!(&contents[byte - 2..byte], "44");
assert_eq!(&contents[byte..byte + 1], " ");
assert_eq!(contents.as_str(), src);
}
#[test]
fn enter_at_last_row_appends_new_row() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut st = editor_at(src, "2");
apply(&mut st, Action::Newline);
let contents = st.contents();
assert!(contents.contains("| | |"));
let new_rows: Vec<&str> = contents.lines().filter(|l| l.starts_with('|')).collect();
assert_eq!(new_rows.len(), 4);
}
#[test]
fn move_right_skips_pipe_separator_in_table() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut st = editor_at(src, "1");
for _ in 0..3 {
apply(&mut st, Action::MoveRight);
}
let byte = st.buffer.rope().char_to_byte(st.cursor.offset);
let contents = st.contents();
let col1_content = contents.find(" 2 ").unwrap() + 1;
assert!(
byte >= col1_content,
"expected cursor past the `|` into cell 1 (>= {col1_content}), got {byte}"
);
}
#[test]
fn move_left_skips_pipe_separator_in_table() {
let src = "| a | b |\n|---|---|\n| 11 | 22 |\n";
let mut st = editor_at(src, "22");
for _ in 0..3 {
apply(&mut st, Action::MoveLeft);
}
let byte = st.buffer.rope().char_to_byte(st.cursor.offset);
let contents = st.contents();
let cell_0_data = contents.find("| 11").unwrap() + 1; let cell_1_pipe = contents.find("| 22").unwrap();
assert!(
byte >= cell_0_data && byte < cell_1_pipe,
"expected cursor within cell 0 of the data row (>= {cell_0_data}, < {cell_1_pipe}), got {byte}"
);
}
#[test]
fn move_right_outside_table_does_not_skip_pipes() {
let src = "abc | def\n";
let mut st = EditorState::new(Buffer::from_str(src), theme());
st.mode = Mode::Rendered;
st.cursor.offset = 4; let before = st.cursor.offset;
apply(&mut st, Action::MoveRight);
assert_eq!(st.cursor.offset, before + 1);
}
#[test]
fn alt_down_swaps_current_row_with_row_below() {
let src = "| a | b |\n|---|---|\n| 11 | 22 |\n| 33 | 44 |\n";
let mut st = editor_at(src, "11");
apply(&mut st, Action::TableMoveRowDown);
let contents = st.contents();
let expected = "| a | b |\n|---|---|\n| 33 | 44 |\n| 11 | 22 |\n";
assert_eq!(contents, expected);
}
#[test]
fn alt_up_swaps_current_row_with_row_above() {
let src = "| a | b |\n|---|---|\n| 11 | 22 |\n| 33 | 44 |\n";
let mut st = editor_at(src, "44");
apply(&mut st, Action::TableMoveRowUp);
let contents = st.contents();
let expected = "| a | b |\n|---|---|\n| 33 | 44 |\n| 11 | 22 |\n";
assert_eq!(contents, expected);
}
#[test]
fn alt_up_on_first_data_row_is_noop() {
let src = "| a | b |\n|---|---|\n| 11 | 22 |\n| 33 | 44 |\n";
let mut st = editor_at(src, "11");
apply(&mut st, Action::TableMoveRowUp);
assert_eq!(st.contents(), src);
}
#[test]
fn alt_shift_down_inserts_new_row_below() {
let src = "| a | b |\n|---|---|\n| 11 | 22 |\n";
let mut st = editor_at(src, "11");
apply(&mut st, Action::TableInsertRowBelow);
let contents = st.contents();
assert!(contents.contains("| 11 | 22 |\n| | |\n"));
}
#[test]
fn alt_shift_up_inserts_new_row_above() {
let src = "| a | b |\n|---|---|\n| 11 | 22 |\n| 33 | 44 |\n";
let mut st = editor_at(src, "33");
apply(&mut st, Action::TableInsertRowAbove);
let contents = st.contents();
assert!(contents.contains("| 11 | 22 |\n| | |\n| 33 | 44 |\n"));
}
#[test]
fn alt_backspace_deletes_current_row() {
let src = "| a | b |\n|---|---|\n| 11 | 22 |\n| 33 | 44 |\n";
let mut st = editor_at(src, "11");
apply(&mut st, Action::TableDeleteRow);
let contents = st.contents();
let expected = "| a | b |\n|---|---|\n| 33 | 44 |\n";
assert_eq!(contents, expected);
}
#[test]
fn alt_backspace_refuses_header_and_alignment() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut st = editor_at(src, "a");
apply(&mut st, Action::TableDeleteRow);
assert_eq!(st.contents(), src);
let mut st = editor_at(src, "---");
apply(&mut st, Action::TableDeleteRow);
assert_eq!(st.contents(), src);
}
#[test]
fn alt_right_swaps_with_column_to_the_right() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut st = editor_at(src, "a");
apply(&mut st, Action::TableMoveColumnRight);
let contents = st.contents();
assert!(contents.starts_with("| b | a |"));
}
#[test]
fn alt_left_swaps_with_column_to_the_left() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut st = editor_at(src, "b");
apply(&mut st, Action::TableMoveColumnLeft);
let contents = st.contents();
assert!(contents.starts_with("| b | a |"));
}
#[test]
fn alt_shift_right_inserts_new_column_on_the_right() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut st = editor_at(src, "a");
apply(&mut st, Action::TableInsertColumnRight);
let contents = st.contents();
let first_line = contents.lines().next().unwrap();
let pipe_count = first_line.matches('|').count();
assert_eq!(pipe_count, 4, "expected 4 `|`s in header of 3-col table");
}
#[test]
fn alt_shift_left_inserts_new_column_on_the_left() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut st = editor_at(src, "b");
apply(&mut st, Action::TableInsertColumnLeft);
let contents = st.contents();
let first_line = contents.lines().next().unwrap();
assert_eq!(first_line.matches('|').count(), 4);
}
#[test]
fn alt_shift_backspace_deletes_column() {
let src = "| a | b | c |\n|---|---|---|\n| 1 | 2 | 3 |\n";
let mut st = editor_at(src, "b");
apply(&mut st, Action::TableDeleteColumn);
let contents = st.contents();
let first_line = contents.lines().next().unwrap();
assert_eq!(first_line.matches('|').count(), 3);
}
#[test]
fn alt_shift_backspace_refuses_to_delete_last_column() {
let src = "| a |\n|---|\n| 1 |\n";
let mut st = editor_at(src, "a");
apply(&mut st, Action::TableDeleteColumn);
assert_eq!(st.contents(), src);
}
#[test]
fn ctrl_z_undoes_structure_edit_in_one_step() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut st = editor_at(src, "1");
apply(&mut st, Action::TableMoveRowDown);
assert_ne!(st.contents(), src);
apply(&mut st, Action::Undo);
assert_eq!(st.contents(), src);
}
#[test]
fn ctrl_z_undoes_row_insertion_in_one_step() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut st = editor_at(src, "1");
apply(&mut st, Action::TableInsertRowBelow);
assert_ne!(st.contents(), src);
apply(&mut st, Action::Undo);
assert_eq!(st.contents(), src);
}
#[test]
fn ctrl_z_undoes_column_deletion_in_one_step() {
let src = "| a | b | c |\n|---|---|---|\n| 1 | 2 | 3 |\n";
let mut st = editor_at(src, "b");
apply(&mut st, Action::TableDeleteColumn);
assert_ne!(st.contents(), src);
apply(&mut st, Action::Undo);
assert_eq!(st.contents(), src);
}
#[test]
fn table_actions_are_noop_outside_table() {
let src = "# Title\n\nJust a paragraph.\n";
let mut st = editor_at(src, "Just");
apply_all(
&mut st,
&[
Action::TableNextCell,
Action::TablePrevCell,
Action::TableMoveRowUp,
Action::TableInsertRowBelow,
Action::TableDeleteRow,
Action::TableDeleteColumn,
],
);
assert_eq!(st.contents(), src);
}
#[test]
fn tab_outside_table_inserts_spaces() {
let src = "hello\n";
let mut st = editor_at(src, "hello");
apply(&mut st, Action::InsertTab);
assert!(st.contents().contains(" hello"));
}
#[test]
fn pipe_inside_cell_is_escaped() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut st = editor_at(src, "1");
apply(&mut st, Action::InsertChar('|'));
let contents = st.contents();
assert!(
contents.contains("| \\|1 |"),
"expected escaped pipe, got: {contents}"
);
let data_row = contents
.lines()
.find(|l| l.contains("1"))
.expect("data row");
let mut unescaped = 0;
let b = data_row.as_bytes();
for i in 0..b.len() {
if b[i] == b'|' && (i == 0 || b[i - 1] != b'\\') {
unescaped += 1;
}
}
assert_eq!(unescaped, 3, "row should still have 3 unescaped `|`s");
}
#[test]
fn pipe_outside_table_is_literal() {
let src = "hello world\n";
let mut st = editor_at(src, "hello");
apply(&mut st, Action::InsertChar('|'));
assert!(st.contents().contains("|hello"));
assert!(!st.contents().contains("\\|"));
}
#[test]
fn shift_enter_inserts_br_inside_cell() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut st = editor_at(src, "1");
apply(&mut st, Action::TableInsertBreak);
let contents = st.contents();
assert!(contents.contains("| <br>1 |"));
let data_rows: Vec<&str> = contents
.lines()
.filter(|l| l.starts_with('|') && !l.starts_with("|-"))
.collect();
assert_eq!(data_rows.len(), 2); }
#[test]
fn shift_enter_outside_table_inserts_newline() {
let src = "hello\n";
let mut st = editor_at(src, "hello");
apply(&mut st, Action::TableInsertBreak);
assert!(st.contents().contains("\nhello"));
}
#[test]
fn move_down_from_header_skips_alignment_row() {
let src = "| a | b |\n|---|---|\n| 11 | 22 |\n";
let mut st = editor_at(src, "a");
apply(&mut st, Action::MoveDown);
let (line, _) = st.cursor.line_col(&st.buffer);
let line_text = st.buffer.line(line).unwrap_or_default();
assert!(
line_text.contains("11"),
"expected cursor on data row, got line {line}: {line_text:?}"
);
}
#[test]
fn move_up_from_first_data_row_skips_alignment_row() {
let src = "| a | b |\n|---|---|\n| 11 | 22 |\n";
let mut st = editor_at(src, "11");
apply(&mut st, Action::MoveUp);
let (line, _) = st.cursor.line_col(&st.buffer);
let line_text = st.buffer.line(line).unwrap_or_default();
assert!(
line_text.contains("| a "),
"expected cursor on header row, got line {line}: {line_text:?}"
);
}
#[test]
fn move_down_between_data_rows_lands_at_cell_end() {
let src = "| a | b |\n|---|---|\n| 11 | 22 |\n| 333 | 444 |\n";
let mut st = editor_at(src, "11");
apply(&mut st, Action::MoveDown);
let byte = st.buffer.rope().char_to_byte(st.cursor.offset);
let contents = st.contents();
assert_eq!(&contents[byte - 1..byte], "3");
assert_eq!(&contents[byte..byte + 1], " ");
}
#[test]
fn move_up_between_data_rows_lands_at_cell_end() {
let src = "| a | b |\n|---|---|\n| 11 | 22 |\n| 333 | 444 |\n";
let mut st = editor_at(src, "333");
apply(&mut st, Action::MoveUp);
let byte = st.buffer.rope().char_to_byte(st.cursor.offset);
let contents = st.contents();
assert_eq!(&contents[byte - 1..byte], "1");
assert_eq!(&contents[byte..byte + 1], " ");
}
#[test]
fn build_snapshots_does_not_panic_after_inline_emoji_paste() {
let src = "a\n\nb";
let mut st = editor_at(src, "b");
let char_off = st.buffer.rope().byte_to_char(1);
st.cursor.offset = char_off;
st.update_cursor_block();
edit_ops::paste_text(&mut st, "🥇", VP, VW);
assert!(
st.parsed_dirty,
"in-line paste must leave `parsed_dirty = true`",
);
let _ = table_view::build_snapshots(&st, Rect::new(0, 0, 80, 24), false);
}
#[test]
fn column_hit_ranges_are_measured_in_cells() {
let src = "| 日本 | ab |\n|---|---|\n| x | y |\n";
let st = editor_at(src, "x");
let snaps = table_view::build_snapshots(&st, Rect::new(0, 0, 80, 24), false);
let snap = snaps.first().expect("a table snapshot");
assert_eq!(snap.col_ranges, vec![1..7, 8..13]);
}
fn char_before_cursor(st: &EditorState) -> char {
let byte = st.buffer.rope().char_to_byte(st.cursor.offset);
st.contents()[..byte].chars().last().unwrap()
}
#[test]
fn tab_visits_every_cell_whatever_the_rows_pipes_or_container() {
let cases = [
("a | b\n--|--\n1 | 2\n", "ab12"),
("| a | b |\n|---|---|\n1 | 2 |\n| 3 | 4\n", "ab1234"),
("> a | b\n> --|--\n> 1 | 2 |\n", "ab12"),
("- item\n\n a | b\n --|--\n 1 | 2\n", "ab12"),
(
"x[^a|b]\n\n[^a|b]: | p | q |\n |---|---|\n | 1 | 2 |\n",
"pq12",
),
(
"| x | y | z |\n|---|---|---|\n| 1 |\n| 2\n| a | b | c | d |\n",
"xyz12abc",
),
];
for (src, cells) in cases {
let first = cells.chars().next().unwrap();
let mut st = editor_at(src, &first.to_string());
assert!(st.cursor_in_table(), "in a table: {src:?}");
let mut seen = String::from(first);
for _ in 1..cells.chars().count() {
apply(&mut st, Action::InsertTab);
seen.push(char_before_cursor(&st));
}
assert_eq!(seen, cells, "Tab in {src:?}");
assert_eq!(st.contents(), src, "Tab edited nothing in {src:?}");
let mut back = String::from(char_before_cursor(&st));
for _ in 1..cells.chars().count() {
apply(&mut st, Action::TablePrevCell);
back.push(char_before_cursor(&st));
}
let reversed: String = cells.chars().rev().collect();
assert_eq!(back, reversed, "Shift+Tab in {src:?}");
}
}
#[test]
fn column_edits_keep_each_rows_pipes() {
let src = "a | b\n--|--\n1 | 2 |\n";
let cases = [
(Action::TableMoveColumnRight, "b | a\n-- | --\n2 | 1 |\n"),
(
Action::TableInsertColumnRight,
"a | | b\n--| --- |--\n1 | | 2 |\n",
),
(Action::TableDeleteColumn, "| b |\n| -- |\n| 2 |\n"),
];
for (action, want) in cases {
let mut st = editor_at(src, "a");
apply(&mut st, action.clone());
assert_eq!(st.contents(), want, "{action:?}");
}
let mut st = editor_at(src, "b");
apply(&mut st, Action::TableInsertColumnRight);
assert_eq!(st.contents(), "a | b | |\n--|-- | ---\n1 | 2 | |\n");
let src = "a | b\n--|--\n1 | - x\n";
let mut st = editor_at(src, "a");
apply(&mut st, Action::TableMoveColumnRight);
assert_eq!(st.contents(), "b | a\n-- | --\n| - x | 1\n");
}
#[test]
fn edits_in_a_quoted_table_keep_the_quote() {
let src = "> a | b\n> --|--\n> 1 | 2\n";
let mut st = editor_at(src, "1");
apply(&mut st, Action::TableMoveColumnRight);
assert_eq!(st.contents(), "> b | a\n> -- | --\n> 2 | 1\n");
apply(&mut st, Action::TableInsertRowBelow);
assert_eq!(st.contents(), "> b | a\n> -- | --\n> 2 | 1\n> | | |\n");
}
fn insert_table_on_blank(src: &str, cursor: usize) -> String {
let mut st = EditorState::new(Buffer::from_str(src), theme());
st.mode = Mode::Rendered;
st.cursor.offset = src[..cursor].chars().count();
edit_ops::insert_table_at_cursor(&mut st, 1, 2, VP, VW);
st.contents()
}
fn table_rows(prefix: &str) -> String {
format!("{prefix}| | |\n{prefix}| --- | --- |\n{prefix}| | |\n")
}
#[test]
fn insert_table_on_a_blank_line_below_an_item_goes_into_the_item() {
let cases = [
("- foo\n", 6, " "),
("- foo\n\n- bar\n", 6, " "),
("1. foo\n\n2. bar\n", 7, " "),
("- outer\n - inner\n\n", 18, " "),
("- outer\n - inner\n\n \n", 19, " "),
("- foo\n\n more\n", 14, " "), ("- foo\n\n more\n\n\n", 15, ""), ("- foo\n\n\n", 7, ""), ("> - foo\n\n", 8, ""), ("para\n\n", 5, ""),
];
for (src, cursor, prefix) in cases {
let post = insert_table_on_blank(src, cursor);
let want = format!(
"{}\n{}{}",
&src[..cursor],
table_rows(prefix),
&src[cursor..]
);
assert_eq!(post, want, "{src:?} at {cursor}");
let blocks = edamame::markdown::parser::parse(&post);
let in_item = blocks
.iter()
.any(|b| matches!(b, edamame::markdown::Block::List { .. }) && contains_table(b));
assert_eq!(in_item, !prefix.is_empty(), "{post:?}");
}
}
fn contains_table(block: &edamame::markdown::Block) -> bool {
use edamame::markdown::Block;
match block {
Block::Table { .. } => true,
Block::List { items, .. } => items.iter().flat_map(|i| &i.blocks).any(contains_table),
_ => false,
}
}