use hjkl_engine::{Editor, Input, Key};
use hjkl_vim::VimEditorExt;
fn editor_with(content: &str) -> Editor {
let opts = hjkl_engine::Options::default();
let mut e = hjkl_vim::vim_editor(
hjkl_buffer::View::new(),
hjkl_engine::DefaultHost::new(),
opts,
);
e.set_content(content);
e
}
fn inp(key: Key) -> Input {
Input {
key,
ctrl: false,
alt: false,
shift: false,
}
}
fn ctrl(key: Key) -> Input {
Input {
key,
ctrl: true,
alt: false,
shift: false,
}
}
fn dispatch_keys(e: &mut Editor, keys: &str) {
let mut iter = keys.chars().peekable();
while let Some(c) = iter.next() {
if c == '<' {
let mut tag = String::new();
for ch in iter.by_ref() {
if ch == '>' {
break;
}
tag.push(ch);
}
let input = match tag.as_str() {
"Esc" => inp(Key::Esc),
"CR" => inp(Key::Enter),
"BS" => inp(Key::Backspace),
"Up" => inp(Key::Up),
"Down" => inp(Key::Down),
s if s.starts_with("C-") => {
let ch = s.chars().nth(2).unwrap();
ctrl(Key::Char(ch))
}
_ => continue,
};
hjkl_vim::dispatch_input(e, input);
} else {
hjkl_vim::dispatch_input(e, inp(Key::Char(c)));
}
}
}
#[test]
fn insert_char_appends_to_buffer() {
let mut e = editor_with("");
e.enter_insert_i(1);
hjkl_vim::dispatch_input(&mut e, inp(Key::Char('x')));
hjkl_vim::dispatch_input(&mut e, inp(Key::Esc));
assert!(
e.content().starts_with('x'),
"dispatch_input should type 'x' in insert mode; got: {:?}",
e.content()
);
}
#[test]
fn insert_mode_esc_returns_to_normal() {
use hjkl_engine::VimMode;
let mut e = editor_with("hello");
e.enter_insert_i(1);
assert_eq!(e.vim_mode(), VimMode::Insert);
hjkl_vim::dispatch_input(&mut e, inp(Key::Esc));
assert_eq!(
e.vim_mode(),
VimMode::Normal,
"Esc via dispatch_input should exit insert mode"
);
}
#[test]
fn insert_backspace_deletes_char() {
let mut e = editor_with("");
e.enter_insert_i(1);
hjkl_vim::dispatch_input(&mut e, inp(Key::Char('a')));
hjkl_vim::dispatch_input(&mut e, inp(Key::Char('b')));
hjkl_vim::dispatch_input(&mut e, inp(Key::Backspace));
hjkl_vim::dispatch_input(&mut e, inp(Key::Esc));
assert!(
e.content().starts_with('a') && !e.content().starts_with("ab"),
"Backspace via dispatch_input should delete last char; got: {:?}",
e.content()
);
}
#[test]
fn insert_ctrl_r_pastes_register() {
let mut e = editor_with("");
e.with_registers_mut(|r| r.record_yank("hi".to_string(), false, Some('z')));
e.enter_insert_i(1);
hjkl_vim::dispatch_input(&mut e, ctrl(Key::Char('r')));
hjkl_vim::dispatch_input(&mut e, inp(Key::Char('z')));
hjkl_vim::dispatch_input(&mut e, inp(Key::Esc));
let content = e.content();
assert!(
content.contains("hi"),
"Ctrl-R z should paste register contents; got: {content:?}"
);
}
#[test]
fn search_forward_commit_moves_cursor() {
let mut e = editor_with("alpha beta");
dispatch_keys(&mut e, "/beta<CR>");
assert_eq!(e.cursor(), (0, 6), "cursor should land on 'beta'");
}
#[test]
fn search_commit_no_match_does_not_push_jump_via_dispatch() {
let mut e = editor_with("alpha beta\nfoo end");
e.jump_cursor(0, 3);
let pre_len = e.jump_back_list().len();
dispatch_keys(&mut e, "/zzznotfound<CR>");
assert_eq!(
e.jump_back_list().len(),
pre_len,
"no match → jumplist should not grow"
);
}
#[test]
fn search_esc_cancels_without_moving_cursor() {
let mut e = editor_with("alpha beta");
let pre = e.cursor();
dispatch_keys(&mut e, "/beta<Esc>");
assert_eq!(e.cursor(), pre, "Esc should not move the cursor");
}
#[test]
fn search_backspace_trims_pattern() {
let mut e = editor_with("alpha beta");
dispatch_keys(&mut e, "/beta<BS><CR>");
assert_eq!(e.cursor(), (0, 6));
}
#[test]
fn sneak_forward_fsm_jumps_to_digraph() {
let mut e = editor_with("foo bar baz qux");
dispatch_keys(&mut e, "sba");
assert_eq!(e.cursor(), (0, 4), "s+ba should land on 'ba' in 'bar'");
}
#[test]
fn sneak_backward_fsm_s_uppercase() {
let mut e = editor_with("foo bar baz qux");
e.jump_cursor(0, 12);
dispatch_keys(&mut e, "Sba");
assert_eq!(e.cursor(), (0, 8), "S+ba backward should land on 'baz'");
}
#[test]
fn sneak_fsm_semicolon_repeats_forward() {
let mut e = editor_with("foo bar baz qux");
dispatch_keys(&mut e, "sba");
assert_eq!(e.cursor(), (0, 4));
dispatch_keys(&mut e, ";");
assert_eq!(
e.cursor(),
(0, 8),
"semicolon after sneak should jump to next 'ba'"
);
}
#[test]
fn sneak_fsm_comma_reverse_no_prior_match() {
let mut e = editor_with("foo bar baz qux");
dispatch_keys(&mut e, "sba");
assert_eq!(e.cursor(), (0, 4));
let pre = e.cursor();
dispatch_keys(&mut e, ",");
assert_eq!(e.cursor(), pre, "comma with no prior 'ba' should not move");
}
#[test]
fn sneak_fsm_operator_pending_delete() {
let mut e = editor_with("hello ab world");
dispatch_keys(&mut e, "dsab");
let content = e.content();
assert!(
content.starts_with("ab world"),
"dsab should delete up to 'ab' leaving 'ab world'; got: {content:?}"
);
}
#[test]
fn sneak_disabled_falls_through_to_substitute_char() {
let mut e = editor_with("foo");
e.settings_mut().motion_sneak = false;
dispatch_keys(&mut e, "sx<Esc>");
let content = e.content();
assert!(
content.starts_with('x'),
"with motion_sneak=false, s should substitute char; got: {content:?}"
);
assert_eq!(e.cursor().1, 0, "cursor should be col 0 after s+char+Esc");
}
#[test]
fn sneak_does_not_leak_count_into_next_command() {
let mut e = editor_with("foo bar baz qux");
dispatch_keys(&mut e, "sba");
assert_eq!(e.cursor(), (0, 4), "s+ba should land on 'ba' in 'bar'");
assert_eq!(e.count(), 0, "sneak must not leave a stale count behind");
dispatch_keys(&mut e, "0");
assert_eq!(e.cursor(), (0, 0), "0 after a sneak must be LineStart");
}
#[test]
fn cancelled_find_drops_count() {
let mut e = editor_with("abcdef");
dispatch_keys(&mut e, "3f<Esc>x");
assert!(
e.content().starts_with("bcdef"),
"3f<Esc> must discard the count; got: {:?}",
e.content()
);
}
#[test]
fn cancelled_replace_drops_count() {
let mut e = editor_with("abcdef");
dispatch_keys(&mut e, "3r<Esc>x");
assert!(
e.content().starts_with("bcdef"),
"3r<Esc> must discard the count; got: {:?}",
e.content()
);
}
#[test]
fn at_digit_plays_numbered_register() {
let mut e = editor_with("ab");
e.with_registers_mut(|r| r.record_delete("x".to_string(), true, None));
dispatch_keys(&mut e, "@1");
assert!(
e.content().starts_with('b'),
"@1 should play the `x` macro in register 1; got: {:?}",
e.content()
);
}
#[test]
fn recursive_macro_terminates() {
let mut e = editor_with("hello");
e.with_registers_mut(|r| r.record_yank("@a".to_string(), false, Some('a')));
dispatch_keys(&mut e, "@a");
}
#[test]
fn search_offset_huge_value_does_not_panic() {
let mut e = editor_with("abx");
dispatch_keys(&mut e, "/x/e+9223372036854775807<CR>");
assert_eq!(e.cursor().0, 0, "cursor stays on the matched row");
let mut e2 = editor_with("foo\nbar x baz");
e2.jump_cursor(1, 0);
dispatch_keys(&mut e2, "/x/-9223372036854775808<CR>");
}
#[test]
fn esc_exits_blame_view() {
let mut e = editor_with("hello\nworld");
e.enter_blame();
assert!(e.is_blame());
dispatch_keys(&mut e, "<Esc>");
assert!(!e.is_blame(), "Esc must exit BLAME via the FSM");
}
#[test]
fn mode_entry_key_exits_blame_view() {
let mut e = editor_with("hello\nworld");
e.enter_blame();
dispatch_keys(&mut e, "v");
assert!(!e.is_blame());
assert_eq!(e.vim_mode(), hjkl_engine::VimMode::Visual);
}
#[test]
fn dot_repeat_count_override_applies_to_insert_mode_change() {
let mut e = editor_with("");
dispatch_keys(&mut e, "3ihello<Esc>");
let after_insert = e.content();
assert_eq!(
after_insert.matches('h').count(),
3,
"3ihello<Esc> must insert 'hello' 3 times; got {after_insert:?}"
);
dispatch_keys(&mut e, "5.");
let after_repeat = e.content();
assert_eq!(
after_repeat.matches('h').count(),
8,
"5. must override the recorded count 3 with 5 (3 + 5 = 8 total 'hello' \
insertions), got {after_repeat:?}"
);
}
#[test]
fn dot_repeat_without_count_reuses_recorded_count() {
let mut e = editor_with("");
dispatch_keys(&mut e, "2iX<Esc>");
assert!(
e.content().starts_with("XX"),
"2iX<Esc> must insert 'X' twice; got {:?}",
e.content()
);
dispatch_keys(&mut e, ".");
assert!(
e.content().starts_with("XXXX"),
"bare . must reuse the recorded count 2 (2 + 2 = 4 'X's); got {:?}",
e.content()
);
}
fn lines_of(e: &Editor) -> Vec<String> {
e.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect()
}
#[test]
fn block_append_pads_rows_shorter_than_the_top_row_to_the_block_edge() {
let mut e = editor_with("ab\nabcdef");
e.jump_cursor(1, 5);
dispatch_keys(&mut e, "<C-v>k");
dispatch_keys(&mut e, "A");
dispatch_keys(&mut e, "X<Esc>");
assert_eq!(
lines_of(&e),
&["ab X".to_string(), "abcdefX".to_string()]
);
}
#[test]
fn block_highlight_delete_bridge_honors_ragged_flag() {
let mut e = editor_with("short\nmuchlongerline");
dispatch_keys(&mut e, "l<C-v>$j");
let (top, bot, left, right) = e.block_highlight().expect("in VisualBlock mode");
e.delete_block(top, bot, left, right, '"');
assert_eq!(lines_of(&e), &["s".to_string(), "m".to_string()]);
}
#[test]
fn block_dollar_delete_removes_to_each_rows_own_eol() {
let mut e = editor_with("short\nmuchlongerline");
dispatch_keys(&mut e, "l<C-v>$jd");
assert_eq!(lines_of(&e), &["s".to_string(), "m".to_string()]);
}
#[test]
fn block_insert_skips_rows_shorter_than_the_block_column() {
let mut e = editor_with("aaaa\nx\nbbbb");
e.jump_cursor(0, 2);
dispatch_keys(&mut e, "<C-v>jj");
dispatch_keys(&mut e, "I");
dispatch_keys(&mut e, "Z<Esc>");
assert_eq!(
lines_of(&e),
&["aaZaa".to_string(), "x".to_string(), "bbZbb".to_string()]
);
}
#[test]
fn insert_unhandled_ctrl_key_is_noop_not_literal_letter() {
let mut e = editor_with("hello\n");
dispatch_keys(&mut e, "i<C-b>x<Esc>");
assert_eq!(
lines_of(&e),
&["xhello".to_string(), String::new()],
"unhandled ctrl key must not insert its letter literally"
);
}
#[test]
fn macro_records_literal_q_in_insert_mode() {
let mut e = editor_with("\n");
dispatch_keys(&mut e, "qaiquick<Esc>q");
let text = e
.with_registers(|r| r.read('a').map(|slot| slot.text.clone()))
.expect("recording must populate register a");
assert_eq!(
text, "iquick<Esc>",
"insert-mode literal q must survive recording; got {text:?}"
);
let mut e2 = editor_with("\n");
e2.with_registers_mut(|r| r.record_yank(text, false, Some('a')));
dispatch_keys(&mut e2, "@a");
assert_eq!(
e2.content().trim_end_matches('\n'),
"quick",
"replay of register a must produce 'quick'; got {:?}",
e2.content()
);
}
#[test]
fn macro_records_q_as_pending_target() {
let mut e = editor_with("xq\n");
dispatch_keys(&mut e, "qb0fqq");
let text = e
.with_registers(|r| r.read('b').map(|slot| slot.text.clone()))
.expect("recording must populate register b");
assert_eq!(
text, "0fq",
"pending-target q must survive recording; got {text:?}"
);
assert_eq!(
e.cursor(),
(0, 1),
"fq must land on the q at col 1 while recording; got {:?}",
e.cursor()
);
}
#[test]
fn join_trailing_whitespace_no_extra_space() {
let mut e = editor_with("hello \nworld");
dispatch_keys(&mut e, "J");
assert_eq!(e.content(), "hello world\n", "trailing space on first line");
}
#[test]
fn join_trailing_tab_no_extra_space() {
let mut e = editor_with("hello\t\nworld");
dispatch_keys(&mut e, "J");
assert_eq!(e.content(), "hello\tworld\n", "trailing tab on first line");
}
#[test]
fn join_leading_paren_no_space() {
let mut e = editor_with("hello\n)world");
dispatch_keys(&mut e, "J");
assert_eq!(e.content(), "hello)world\n", "leading ) on second line");
}
#[test]
fn join_plain_inserts_space() {
let mut e = editor_with("hello\nworld");
dispatch_keys(&mut e, "J");
assert_eq!(e.content(), "hello world\n");
}