use hjkl_vim_types::{LastChange, LastHorizontalMotion, Motion, Operator, RangeKind, TextObject};
use super::*;
use crate::vim_state::{vim, vim_mut};
use hjkl_engine::Editor;
use hjkl_engine::buf_helpers::{buf_line, buf_line_chars, buf_row_count, buf_set_cursor_rc};
pub fn apply_sneak<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
c1: char,
c2: char,
forward: bool,
count: usize,
) {
let count = count.max(1);
let (start_row, start_col) = ed.cursor();
let row_count = buf_row_count(ed.buffer());
let result = if forward {
sneak_scan_forward(ed, start_row, start_col, c1, c2, count)
} else {
sneak_scan_backward(ed, start_row, start_col, c1, c2, count)
};
if let Some((row, col)) = result {
buf_set_cursor_rc(ed.buffer_mut(), row, col);
let _ = row_count; }
vim_mut(ed).last_sneak = Some(((c1, c2), forward));
vim_mut(ed).last_horizontal_motion = LastHorizontalMotion::Sneak;
}
pub fn sneak_scan_forward<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
start_row: usize,
start_col: usize,
c1: char,
c2: char,
count: usize,
) -> Option<(usize, usize)> {
let row_count = buf_row_count(ed.buffer());
let mut hits = 0usize;
for row in start_row..row_count {
let line = buf_line(ed.buffer(), row).unwrap_or_default();
let chars: Vec<char> = line.chars().collect();
let col_start = if row == start_row { start_col + 1 } else { 0 };
if col_start + 1 > chars.len() {
continue;
}
for col in col_start..chars.len().saturating_sub(1) {
if chars[col] == c1 && chars[col + 1] == c2 {
hits += 1;
if hits == count {
return Some((row, col));
}
}
}
}
None
}
pub fn sneak_scan_backward<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
start_row: usize,
start_col: usize,
c1: char,
c2: char,
count: usize,
) -> Option<(usize, usize)> {
let row_count = buf_row_count(ed.buffer());
let mut hits = 0usize;
let rows_to_scan = (0..row_count).rev().skip(row_count - start_row - 1);
for row in rows_to_scan {
let line = buf_line(ed.buffer(), row).unwrap_or_default();
let chars: Vec<char> = line.chars().collect();
let col_end = if row == start_row {
start_col.saturating_sub(1)
} else if chars.is_empty() {
continue;
} else {
chars.len().saturating_sub(1)
};
if col_end == 0 {
continue;
}
for col in (0..col_end).rev() {
if col + 1 < chars.len() && chars[col] == c1 && chars[col + 1] == c2 {
hits += 1;
if hits == count {
return Some((row, col));
}
}
}
}
None
}
pub fn apply_op_sneak<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
op: Operator,
c1: char,
c2: char,
forward: bool,
total_count: usize,
) {
let start = ed.cursor();
let result = if forward {
sneak_scan_forward(ed, start.0, start.1, c1, c2, total_count)
} else {
sneak_scan_backward(ed, start.0, start.1, c1, c2, total_count)
};
let Some(end) = result else {
return;
};
ed.jump_cursor(end.0, end.1);
let end_cur = ed.cursor();
ed.jump_cursor(start.0, start.1);
run_operator_over_range(ed, op, start, end_cur, RangeKind::Exclusive);
vim_mut(ed).last_sneak = Some(((c1, c2), forward));
vim_mut(ed).last_horizontal_motion = LastHorizontalMotion::Sneak;
if !vim(ed).replaying && op_is_change(op) {
}
}
pub fn apply_op_find_motion<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
op: Operator,
ch: char,
forward: bool,
till: bool,
total_count: usize,
) {
let motion = Motion::Find { ch, forward, till };
apply_op_with_motion(ed, op, &motion, total_count);
vim_mut(ed).last_find = Some((ch, forward, till));
if !vim(ed).replaying && op_is_change(op) {
vim_mut(ed).last_change = Some(LastChange::OpMotion {
op,
motion,
count: total_count,
inserted: None,
});
}
}
pub fn apply_op_text_obj_inner<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
op: Operator,
ch: char,
inner: bool,
total_count: usize,
) -> bool {
let obj = match ch {
'w' => TextObject::Word { big: false },
'W' => TextObject::Word { big: true },
'"' | '\'' | '`' => TextObject::Quote(ch),
'(' | ')' | 'b' => TextObject::Bracket('('),
'[' | ']' => TextObject::Bracket('['),
'{' | '}' | 'B' => TextObject::Bracket('{'),
'<' | '>' => TextObject::Bracket('<'),
'p' => TextObject::Paragraph,
't' => TextObject::XmlTag,
's' => TextObject::Sentence,
_ => return false,
};
apply_op_with_text_object(ed, op, obj, inner, total_count.max(1));
if !vim(ed).replaying && op_is_change(op) {
vim_mut(ed).last_change = Some(LastChange::OpTextObj {
op,
obj,
inner,
inserted: None,
});
}
true
}
pub fn retreat_one<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
pos: (usize, usize),
) -> (usize, usize) {
let (r, c) = pos;
if c > 0 {
(r, c - 1)
} else if r > 0 {
let prev_len = buf_line_chars(ed.buffer(), r - 1);
(r - 1, prev_len)
} else {
(0, 0)
}
}
#[cfg(test)]
mod sneak_tests {
use super::*;
use hjkl_buffer::View;
use hjkl_engine::{DefaultHost, Editor, Options};
fn make_editor(content: &str) -> Editor<View, DefaultHost> {
let buf = View::from_str(content);
let host = DefaultHost::new();
crate::vim::vim_editor(buf, host, Options::default())
}
#[test]
fn sneak_forward_jumps_to_two_char_digraph() {
let mut ed = make_editor("foo bar baz qux\n");
ed.jump_cursor(0, 0);
super::apply_sneak(&mut ed, 'b', 'a', true, 1);
assert_eq!(ed.cursor(), (0, 4), "cursor should land on 'ba' in 'bar'");
}
#[test]
fn sneak_backward_jumps_to_prior_match() {
let mut ed = make_editor("foo bar baz qux\n");
ed.jump_cursor(0, 12);
super::apply_sneak(&mut ed, 'b', 'a', false, 1);
assert_eq!(
ed.cursor(),
(0, 8),
"backward sneak should find 'ba' in 'baz'"
);
}
#[test]
fn sneak_repeat_semicolon_next_match() {
let mut ed = make_editor("foo bar baz qux\n");
ed.jump_cursor(0, 0);
super::apply_sneak(&mut ed, 'b', 'a', true, 1);
assert_eq!(ed.cursor(), (0, 4));
execute_motion(&mut ed, Motion::FindRepeat { reverse: false }, 1);
assert_eq!(ed.cursor(), (0, 8), "semicolon should jump to next 'ba'");
}
#[test]
fn sneak_repeat_comma_prev_match() {
let mut ed = make_editor("foo bar baz qux\n");
ed.jump_cursor(0, 0);
super::apply_sneak(&mut ed, 'b', 'a', true, 1);
assert_eq!(ed.cursor(), (0, 4));
let pre = ed.cursor();
execute_motion(&mut ed, Motion::FindRepeat { reverse: true }, 1);
assert_eq!(
ed.cursor(),
pre,
"comma with no prior match should leave cursor unchanged"
);
}
#[test]
fn sneak_s_searches_backward() {
let mut ed = make_editor("foo bar baz qux\n");
ed.jump_cursor(0, 12);
super::apply_sneak(&mut ed, 'b', 'a', false, 1);
assert_eq!(ed.cursor(), (0, 8));
}
#[test]
fn sneak_with_count_jumps_to_nth() {
let mut ed = make_editor("foo bar baz qux\n");
ed.jump_cursor(0, 0);
super::apply_sneak(&mut ed, 'b', 'a', true, 2);
assert_eq!(ed.cursor(), (0, 8), "count=2 should jump to 2nd 'ba'");
}
#[test]
fn sneak_no_match_cursor_stays() {
let mut ed = make_editor("foo bar baz qux\n");
ed.jump_cursor(0, 0);
let pre = ed.cursor();
super::apply_sneak(&mut ed, 'x', 'x', true, 1);
assert_eq!(ed.cursor(), pre, "no match should leave cursor unchanged");
}
#[test]
fn operator_pending_dsab_deletes_to_digraph() {
let mut ed = make_editor("hello ab world\n");
ed.jump_cursor(0, 0);
super::apply_op_sneak(&mut ed, Operator::Delete, 'a', 'b', true, 1);
let content = ed.content();
assert!(
content.starts_with("ab world"),
"dsab should delete 'hello ' leaving 'ab world'; got: {content:?}"
);
}
#[test]
fn sneak_cross_line_match() {
let mut ed = make_editor("foo\nbar baz\n");
ed.jump_cursor(0, 0);
super::apply_sneak(&mut ed, 'b', 'a', true, 1);
assert_eq!(ed.cursor(), (1, 0), "sneak should cross line boundary");
}
#[test]
fn sneak_updates_last_sneak_state() {
let mut ed = make_editor("foo bar baz\n");
ed.jump_cursor(0, 0);
super::apply_sneak(&mut ed, 'b', 'a', true, 1);
let ls = vim(&ed).last_sneak;
assert_eq!(
ls,
Some((('b', 'a'), true)),
"last_sneak should record the digraph and direction"
);
}
#[test]
fn retreat_one_vi_brace_prev_line_lands_on_char_length() {
let mut ed = make_editor("fn foo() {\n body\n}\n");
ed.jump_cursor(1, 4); let (_start, end, kind) =
crate::vim::text_object::text_object_range(&ed, TextObject::Bracket('{'), true, 1)
.expect("vi{ should resolve the enclosing braces");
assert_eq!(kind, RangeKind::Exclusive);
assert_eq!(
end,
(2, 0),
"exclusive inner end should be col 0 of the '}}' line"
);
let landed = super::retreat_one(&ed, end);
assert_eq!(
landed,
(1, 8),
"visual cursor should land on char-col 8 (char_len of \" body\"), \
the virtual one-past-end column that makes the delete join lines"
);
}
#[test]
fn retreat_one_vi_brace_multibyte_prev_line_uses_char_column() {
let mut ed = make_editor("fn foo() {\n héllo\n}\n");
ed.jump_cursor(1, 4); let (_start, end, _kind) =
crate::vim::text_object::text_object_range(&ed, TextObject::Bracket('{'), true, 1)
.expect("vi{ should resolve the enclosing braces");
assert_eq!(
end,
(2, 0),
"exclusive inner end should be col 0 of the '}}' line"
);
let landed = super::retreat_one(&ed, end);
assert_eq!(
landed,
(1, 9),
"visual cursor should land on char-col 9 (char_len of \" héllo\"), \
not the byte length (10)"
);
}
}