#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VimKey {
Char(char),
Esc,
Enter,
Backspace,
Left,
Right,
Up,
Down,
Home,
End,
Redo,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VimOutcome {
None,
Submit,
Cancel,
SpellSuggest,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Normal,
Insert,
Command,
}
#[derive(Debug)]
pub struct VimEdit {
text: String,
cursor: usize,
vcol: usize,
mode: Mode,
cmdline: String,
status: Option<String>,
count: Option<usize>,
op: Option<char>,
await_r: bool,
await_g: bool,
await_z: bool,
await_textobj: Option<char>,
undo: Vec<(String, usize)>,
redo: Vec<(String, usize)>,
change_open: bool,
cur: Vec<VimKey>,
dot: Vec<VimKey>,
cur_is_change: bool,
replaying: bool,
}
impl VimEdit {
pub fn new(text: String, cursor: usize) -> Self {
let mut v = Self {
text,
cursor: 0,
vcol: 0,
mode: Mode::Normal,
cmdline: String::new(),
status: None,
count: None,
op: None,
await_r: false,
await_g: false,
await_z: false,
await_textobj: None,
undo: Vec::new(),
redo: Vec::new(),
change_open: false,
cur: Vec::new(),
dot: Vec::new(),
cur_is_change: false,
replaying: false,
};
v.cursor = cursor.min(v.text.len());
while v.cursor > 0 && !v.text.is_char_boundary(v.cursor) {
v.cursor -= 1;
}
v.clamp_normal();
v.vcol = v.col(v.cursor);
v
}
pub fn text(&self) -> &str {
&self.text
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn mode(&self) -> Mode {
self.mode
}
pub fn replace_range(&mut self, start: usize, end: usize, repl: &str) {
if start > end || end > self.text.len() || !self.text.is_char_boundary(start) {
return;
}
self.undo.push((self.text.clone(), self.cursor));
self.redo.clear();
self.change_open = false;
self.text.replace_range(start..end, repl);
self.cursor = start;
self.clamp_normal();
self.vcol = self.col(self.cursor);
}
pub fn set_cursor(&mut self, pos: usize) {
self.cursor = pos.min(self.text.len());
self.clamp_normal();
self.vcol = self.col(self.cursor);
}
pub fn status_line(&self) -> String {
match self.mode {
Mode::Normal => self
.status
.clone()
.unwrap_or_else(|| "-- NORMAL --".to_string()),
Mode::Insert => "-- INSERT --".to_string(),
Mode::Command => self.cmdline.clone(),
}
}
pub fn handle(&mut self, key: VimKey) -> VimOutcome {
if !self.replaying {
let clean = self.mode == Mode::Normal
&& self.op.is_none()
&& !self.await_r
&& !self.await_g
&& !self.await_z
&& self.await_textobj.is_none();
if clean {
self.change_open = false;
if self.cur_is_change {
self.dot = std::mem::take(&mut self.cur);
}
self.cur.clear();
self.cur_is_change = false;
}
self.cur.push(key);
}
match self.mode {
Mode::Insert => {
self.handle_insert(key);
VimOutcome::None
}
Mode::Command => self.handle_command(key),
Mode::Normal => self.handle_normal(key),
}
}
fn begin_change(&mut self) {
if !self.change_open {
self.undo.push((self.text.clone(), self.cursor));
self.redo.clear();
self.change_open = true;
if !self.replaying {
self.cur_is_change = true;
}
}
}
fn undo(&mut self) {
if let Some((text, cursor)) = self.undo.pop() {
self.redo
.push((std::mem::take(&mut self.text), self.cursor));
self.text = text;
self.cursor = cursor;
self.clamp_normal();
self.vcol = self.col(self.cursor);
}
}
fn redo(&mut self) {
if let Some((text, cursor)) = self.redo.pop() {
self.undo
.push((std::mem::take(&mut self.text), self.cursor));
self.text = text;
self.cursor = cursor;
self.clamp_normal();
self.vcol = self.col(self.cursor);
}
}
fn repeat(&mut self) {
if self.dot.is_empty() || self.replaying {
return;
}
let dot = self.dot.clone();
self.replaying = true;
self.change_open = false; for k in dot {
self.handle(k);
}
self.replaying = false;
}
fn handle_normal(&mut self, key: VimKey) -> VimOutcome {
self.status = None;
if self.await_r {
self.await_r = false;
let n = self.count.take().unwrap_or(1);
if let VimKey::Char(c) = key {
self.replace_char(c, n);
}
return VimOutcome::None;
}
if self.await_g {
self.await_g = false;
if matches!(key, VimKey::Char('g')) {
self.cursor = 0;
self.vcol = 0;
self.clamp_normal();
}
self.reset_pending();
return VimOutcome::None;
}
if self.await_z {
self.await_z = false;
self.reset_pending();
if matches!(key, VimKey::Char('=')) {
return VimOutcome::SpellSuggest;
}
return VimOutcome::None;
}
if let Some(ia) = self.await_textobj.take() {
if matches!(key, VimKey::Char('w')) {
let (s, e) = if ia == 'i' {
self.inner_word_bounds()
} else {
self.a_word_bounds()
};
if let Some(op) = self.op.take() {
self.apply_op_range(op, s, e);
}
}
self.reset_pending();
return VimOutcome::None;
}
let key = match key {
VimKey::Left => VimKey::Char('h'),
VimKey::Right => VimKey::Char('l'),
VimKey::Up => VimKey::Char('k'),
VimKey::Down => VimKey::Char('j'),
other => other,
};
match key {
VimKey::Esc => {
if self.op.is_some() || self.count.is_some() {
self.reset_pending();
VimOutcome::None
} else {
VimOutcome::Cancel
}
}
VimKey::Enter => VimOutcome::Submit,
VimKey::Backspace => {
self.move_motion('h');
VimOutcome::None
}
VimKey::Home => {
self.cursor = self.line_start(self.cursor);
self.vcol = 0;
self.reset_pending();
VimOutcome::None
}
VimKey::End => {
self.cursor = self.line_last_char(self.cursor);
self.vcol = self.col(self.cursor);
self.reset_pending();
VimOutcome::None
}
VimKey::Redo => {
self.redo();
self.reset_pending();
VimOutcome::None
}
VimKey::Char(c) => self.handle_normal_char(c),
_ => VimOutcome::None,
}
}
fn handle_normal_char(&mut self, c: char) -> VimOutcome {
if c.is_ascii_digit() && !(c == '0' && self.count.is_none()) {
let d = c as usize - '0' as usize;
self.count = Some(self.count.unwrap_or(0) * 10 + d);
return VimOutcome::None;
}
if let Some(op) = self.op {
if c == op {
self.op = None;
self.count = None;
self.linewise_op(op);
return VimOutcome::None;
}
if c == 'i' || c == 'a' {
self.await_textobj = Some(c);
return VimOutcome::None;
}
let n = self.count.take().unwrap_or(1);
let motion = if op == 'c' && c == 'w' && self.class_at(self.cursor) != 0 {
'e'
} else {
c
};
if let Some((s, e)) = self.op_span(motion, n) {
self.op = None;
self.apply_op_range(op, s, e);
} else {
self.reset_pending();
}
return VimOutcome::None;
}
match c {
'd' | 'c' => self.op = Some(c),
'i' => self.enter_insert_at(self.cursor),
'a' => {
let p = self
.next_boundary(self.cursor)
.min(self.line_end(self.cursor));
self.enter_insert_at(p);
}
'I' => {
let p = self.first_non_blank(self.cursor);
self.enter_insert_at(p);
}
'A' => {
let p = self.line_end(self.cursor);
self.enter_insert_at(p);
}
'o' => {
self.begin_change();
let le = self.line_end(self.cursor);
self.text.insert(le, '\n');
self.enter_insert_at(le + 1);
}
'O' => {
self.begin_change();
let ls = self.line_start(self.cursor);
self.text.insert(ls, '\n');
self.enter_insert_at(ls);
}
'x' => {
let n = self.count.take().unwrap_or(1);
self.delete_chars(n);
}
's' => {
let n = self.count.take().unwrap_or(1);
self.delete_chars(n);
self.mode = Mode::Insert;
}
'S' => self.linewise_op('c'),
'D' => {
let e = self.line_end(self.cursor);
self.delete_range(self.cursor, e);
}
'C' => {
let e = self.line_end(self.cursor);
self.delete_range(self.cursor, e);
self.mode = Mode::Insert;
}
'r' => self.await_r = true,
'g' => self.await_g = true,
'z' => self.await_z = true,
'u' => self.undo(),
'.' => self.repeat(),
':' => {
self.mode = Mode::Command;
self.cmdline = String::from(":");
}
'h' | 'l' | 'w' | 'b' | 'e' | 'j' | 'k' | '0' | '^' | '$' | 'G' => self.move_motion(c),
_ => self.count = None,
}
VimOutcome::None
}
fn reset_pending(&mut self) {
self.count = None;
self.op = None;
self.await_r = false;
self.await_g = false;
self.await_z = false;
self.await_textobj = None;
}
fn enter_insert_at(&mut self, pos: usize) {
self.cursor = pos;
self.mode = Mode::Insert;
self.reset_pending();
}
fn move_motion(&mut self, c: char) {
let n = self.count.take().unwrap_or(1);
let vertical = matches!(c, 'j' | 'k');
let mut p = self.cursor;
match c {
'h' => {
for _ in 0..n {
p = self.step_left_on_line(p);
}
}
'l' => {
for _ in 0..n {
p = self.step_right_on_line(p);
}
}
'w' => {
for _ in 0..n {
p = self.next_word_start(p);
}
}
'b' => {
for _ in 0..n {
p = self.prev_word_start(p);
}
}
'e' => {
for _ in 0..n {
p = self.next_word_end(p);
}
}
'j' => {
for _ in 0..n {
let le = self.line_end(p);
if le >= self.text.len() {
break;
}
p = self.place_col(le + 1, self.vcol);
}
}
'k' => {
for _ in 0..n {
let ls = self.line_start(p);
if ls == 0 {
break;
}
p = self.place_col(self.line_start(ls - 1), self.vcol);
}
}
'0' => p = self.line_start(p),
'^' => p = self.first_non_blank(p),
'$' => p = self.line_last_char(p),
'G' => p = self.last_line_start(),
_ => {}
}
self.cursor = p;
self.clamp_normal();
if !vertical {
self.vcol = self.col(self.cursor);
}
}
fn op_span(&self, motion: char, n: usize) -> Option<(usize, usize)> {
let from = self.cursor;
let mut p = from;
let span = match motion {
'w' => {
for _ in 0..n {
p = self.next_word_start(p);
}
(from, p)
}
'e' => {
for _ in 0..n {
p = self.next_word_end(p);
}
(from, self.next_boundary(p)) }
'b' => {
for _ in 0..n {
p = self.prev_word_start(p);
}
(p, from)
}
'h' => {
for _ in 0..n {
p = self.step_left_on_line(p);
}
(p, from)
}
'l' => {
for _ in 0..n {
p = self.next_boundary_on_line(p);
}
(from, p)
}
'0' => (self.line_start(from), from),
'^' => {
let t = self.first_non_blank(from);
(t.min(from), t.max(from))
}
'$' => (from, self.line_end(from)),
_ => return None,
};
Some((span.0.min(span.1), span.0.max(span.1)))
}
fn apply_op_range(&mut self, op: char, start: usize, end: usize) {
self.begin_change();
if start < end {
self.text.replace_range(start..end, "");
self.cursor = start;
}
if op == 'c' {
self.mode = Mode::Insert;
} else {
self.clamp_normal();
self.vcol = self.col(self.cursor);
}
}
fn linewise_op(&mut self, op: char) {
self.begin_change();
let ls = self.line_start(self.cursor);
let le = self.line_end(self.cursor);
if op == 'c' {
self.text.replace_range(ls..le, "");
self.cursor = ls;
self.mode = Mode::Insert;
} else {
let end = if le < self.text.len() {
self.next_boundary(le)
} else {
le
};
let start = if end == le && ls > 0 {
self.prev_boundary(ls)
} else {
ls
};
self.text.replace_range(start..end, "");
self.cursor = self.line_start(start.min(self.text.len()));
self.clamp_normal();
self.vcol = self.col(self.cursor);
}
}
fn delete_chars(&mut self, n: usize) {
let le = self.line_end(self.cursor);
let mut e = self.cursor;
for _ in 0..n {
if e < le {
e = self.next_boundary(e);
}
}
self.delete_range(self.cursor, e);
}
fn delete_range(&mut self, start: usize, end: usize) {
if start < end {
self.begin_change();
self.text.replace_range(start..end, "");
self.cursor = start.min(self.text.len());
self.clamp_normal();
self.vcol = self.col(self.cursor);
}
}
fn replace_char(&mut self, c: char, n: usize) {
let le = self.line_end(self.cursor);
let mut end = self.cursor;
for _ in 0..n {
if end < le {
end = self.next_boundary(end);
}
}
if end == self.cursor {
return;
}
self.begin_change();
let n_chars = self.text[self.cursor..end].chars().count();
let repl: String = std::iter::repeat_n(c, n_chars).collect();
let repl_len = repl.len();
self.text.replace_range(self.cursor..end, &repl);
self.cursor = self.cursor + repl_len - c.len_utf8();
self.clamp_normal();
self.vcol = self.col(self.cursor);
}
fn handle_insert(&mut self, key: VimKey) {
match key {
VimKey::Esc => {
self.mode = Mode::Normal;
let ls = self.line_start(self.cursor);
if self.cursor > ls {
self.cursor = self.prev_boundary(self.cursor);
}
self.clamp_normal();
self.vcol = self.col(self.cursor);
}
VimKey::Enter => {
self.begin_change();
self.text.insert(self.cursor, '\n');
self.cursor += 1;
}
VimKey::Backspace => {
if self.cursor > 0 {
self.begin_change();
let pb = self.prev_boundary(self.cursor);
self.text.replace_range(pb..self.cursor, "");
self.cursor = pb;
}
}
VimKey::Left => self.cursor = self.prev_boundary(self.cursor),
VimKey::Right => self.cursor = self.next_boundary(self.cursor).min(self.text.len()),
VimKey::Up => self.cursor = self.line_up(self.cursor),
VimKey::Down => self.cursor = self.line_down(self.cursor),
VimKey::Home => self.cursor = self.line_start(self.cursor),
VimKey::End => self.cursor = self.line_end(self.cursor),
VimKey::Char(c) => {
self.begin_change();
self.text.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
VimKey::Redo => {}
}
}
fn handle_command(&mut self, key: VimKey) -> VimOutcome {
match key {
VimKey::Esc => {
self.mode = Mode::Normal;
self.cmdline.clear();
VimOutcome::None
}
VimKey::Backspace => {
self.cmdline.pop();
if self.cmdline.is_empty() {
self.mode = Mode::Normal;
}
VimOutcome::None
}
VimKey::Enter => {
let cmd = self.cmdline.trim_start_matches(':').trim().to_string();
self.cmdline.clear();
self.mode = Mode::Normal;
match cmd.as_str() {
"w" | "wq" | "x" | "wq!" | "w!" | "x!" | "wqa" | "xa" => VimOutcome::Submit,
"q" | "q!" | "qa" | "qa!" => VimOutcome::Cancel,
other => {
self.status = Some(format!("E492: Not an editor command: {other}"));
VimOutcome::None
}
}
}
VimKey::Char(c) => {
self.cmdline.push(c);
VimOutcome::None
}
_ => VimOutcome::None,
}
}
fn clamp_normal(&mut self) {
while self.cursor > 0 && !self.text.is_char_boundary(self.cursor) {
self.cursor -= 1;
}
let last = self.line_end(self.cursor);
if self.cursor > last {
self.cursor = last;
}
}
fn prev_boundary(&self, pos: usize) -> usize {
self.text[..pos]
.char_indices()
.next_back()
.map_or(0, |(i, _)| i)
}
fn next_boundary(&self, pos: usize) -> usize {
self.text[pos..]
.chars()
.next()
.map_or(pos, |c| pos + c.len_utf8())
}
fn line_start(&self, pos: usize) -> usize {
self.text[..pos].rfind('\n').map_or(0, |i| i + 1)
}
fn line_end(&self, pos: usize) -> usize {
self.text[pos..]
.find('\n')
.map_or(self.text.len(), |i| pos + i)
}
fn line_last_char(&self, pos: usize) -> usize {
let ls = self.line_start(pos);
let le = self.line_end(pos);
if le > ls { self.prev_boundary(le) } else { ls }
}
fn first_non_blank(&self, pos: usize) -> usize {
let ls = self.line_start(pos);
let le = self.line_end(pos);
let mut i = ls;
while i < le {
let c = self.text[i..].chars().next().unwrap();
if !c.is_whitespace() {
return i;
}
i += c.len_utf8();
}
ls
}
fn step_left_on_line(&self, pos: usize) -> usize {
let ls = self.line_start(pos);
if pos > ls {
self.prev_boundary(pos)
} else {
pos
}
}
fn step_right_on_line(&self, pos: usize) -> usize {
let end = self.line_end(pos);
if pos < end {
self.next_boundary(pos)
} else {
pos
}
}
fn next_boundary_on_line(&self, pos: usize) -> usize {
self.next_boundary(pos).min(self.line_end(pos))
}
fn col(&self, pos: usize) -> usize {
self.text[self.line_start(pos)..pos].chars().count()
}
fn place_col(&self, line_start: usize, col: usize) -> usize {
let le = self.line_end(line_start);
let mut i = line_start;
let mut c = 0;
while c < col && i < le {
i = self.next_boundary(i);
c += 1;
}
i
}
fn line_down(&self, pos: usize) -> usize {
let le = self.line_end(pos);
if le >= self.text.len() {
return pos;
}
self.place_col(le + 1, self.col(pos))
}
fn line_up(&self, pos: usize) -> usize {
let ls = self.line_start(pos);
if ls == 0 {
return pos;
}
let prev_ls = self.line_start(ls - 1);
self.place_col(prev_ls, self.col(pos))
}
fn last_line_start(&self) -> usize {
self.first_non_blank(self.text[..].rfind('\n').map_or(0, |i| i + 1))
}
fn class_at(&self, pos: usize) -> u8 {
match self.text[pos..].chars().next() {
None => 0,
Some(c) if c.is_whitespace() => 0,
Some(c) if c.is_alphanumeric() || c == '_' || c == '\'' => 1,
Some(_) => 2,
}
}
fn next_word_start(&self, pos: usize) -> usize {
let len = self.text.len();
if pos >= len {
return len;
}
let mut i = pos;
let cls = self.class_at(i);
if cls != 0 {
while i < len && self.class_at(i) == cls {
i = self.next_boundary(i);
}
}
while i < len && self.class_at(i) == 0 {
i = self.next_boundary(i);
}
i
}
fn next_word_end(&self, pos: usize) -> usize {
let len = self.text.len();
if pos >= len {
return pos;
}
let mut i = self.next_boundary(pos);
while i < len && self.class_at(i) == 0 {
i = self.next_boundary(i);
}
if i >= len {
return self.prev_boundary(len);
}
let cls = self.class_at(i);
loop {
let nb = self.next_boundary(i);
if nb < len && self.class_at(nb) == cls {
i = nb;
} else {
break;
}
}
i
}
fn prev_word_start(&self, pos: usize) -> usize {
if pos == 0 {
return 0;
}
let mut i = self.prev_boundary(pos);
while i > 0 && self.class_at(i) == 0 {
i = self.prev_boundary(i);
}
if self.class_at(i) == 0 {
return i;
}
let cls = self.class_at(i);
while i > 0 {
let pb = self.prev_boundary(i);
if self.class_at(pb) == cls {
i = pb;
} else {
break;
}
}
i
}
fn inner_word_bounds(&self) -> (usize, usize) {
let pos = self.cursor;
let len = self.text.len();
if len == 0 {
return (0, 0);
}
let cls = self.class_at(pos);
let mut s = pos;
while s > 0 {
let pb = self.prev_boundary(s);
if self.class_at(pb) == cls {
s = pb;
} else {
break;
}
}
let mut e = pos;
while e < len && self.class_at(e) == cls {
e = self.next_boundary(e);
}
(s, e)
}
fn a_word_bounds(&self) -> (usize, usize) {
let (s, mut e) = self.inner_word_bounds();
let len = self.text.len();
if e < len && self.class_at(e) == 0 {
while e < len && self.class_at(e) == 0 {
e = self.next_boundary(e);
}
return (s, e);
}
let mut s2 = s;
while s2 > 0 {
let pb = self.prev_boundary(s2);
if self.class_at(pb) == 0 {
s2 = pb;
} else {
break;
}
}
(s2, e)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn feed(v: &mut VimEdit, s: &str) -> VimOutcome {
let mut out = VimOutcome::None;
for c in s.chars() {
out = v.handle(VimKey::Char(c));
}
out
}
#[test]
fn x_deletes_char_under_cursor() {
let mut v = VimEdit::new("abc".into(), 0);
feed(&mut v, "x");
assert_eq!(v.text(), "bc");
assert_eq!(v.cursor(), 0);
}
#[test]
fn count_x_deletes_n_chars() {
let mut v = VimEdit::new("abcdef".into(), 0);
feed(&mut v, "3x");
assert_eq!(v.text(), "def");
}
#[test]
fn dw_deletes_word_and_trailing_space() {
let mut v = VimEdit::new("the quick".into(), 0);
feed(&mut v, "dw");
assert_eq!(v.text(), "quick");
}
#[test]
fn diw_deletes_inner_word_keeping_space() {
let mut v = VimEdit::new("the quick".into(), 0);
feed(&mut v, "diw");
assert_eq!(v.text(), " quick");
}
#[test]
fn ciw_then_type_replaces_the_word() {
let mut v = VimEdit::new("teh quick".into(), 0);
feed(&mut v, "ciw");
assert_eq!(v.mode(), Mode::Insert);
assert_eq!(v.text(), " quick");
feed(&mut v, "the");
assert_eq!(v.text(), "the quick");
assert_eq!(v.cursor(), 3);
}
#[test]
fn cw_behaves_like_ce_changing_to_end_of_word() {
let mut v = VimEdit::new("teh quick".into(), 0);
feed(&mut v, "cw");
assert_eq!(v.text(), " quick");
assert_eq!(v.mode(), Mode::Insert);
feed(&mut v, "the");
assert_eq!(v.text(), "the quick");
}
#[test]
fn daw_deletes_word_with_its_space() {
let mut v = VimEdit::new("the quick fox".into(), 4); feed(&mut v, "daw");
assert_eq!(v.text(), "the fox");
}
#[test]
fn replace_char_with_r() {
let mut v = VimEdit::new("cat".into(), 0);
feed(&mut v, "rb");
assert_eq!(v.text(), "bat");
assert_eq!(v.cursor(), 0);
}
#[test]
fn word_motions_move_the_cursor() {
let mut v = VimEdit::new("the quick brown".into(), 0);
feed(&mut v, "w");
assert_eq!(v.cursor(), 4); feed(&mut v, "e");
assert_eq!(v.cursor(), 8); feed(&mut v, "b");
assert_eq!(v.cursor(), 4); }
#[test]
fn count_word_motion() {
let mut v = VimEdit::new("one two three four".into(), 0);
feed(&mut v, "2w");
assert_eq!(v.cursor(), 8); }
#[test]
fn dollar_and_zero_jump_to_line_edges() {
let mut v = VimEdit::new("hello world".into(), 0);
feed(&mut v, "$");
assert_eq!(v.cursor(), 10); feed(&mut v, "0");
assert_eq!(v.cursor(), 0);
}
#[test]
fn append_inserts_after_cursor() {
let mut v = VimEdit::new("ab".into(), 0);
feed(&mut v, "a"); assert_eq!(v.mode(), Mode::Insert);
feed(&mut v, "X");
assert_eq!(v.text(), "aXb");
}
#[test]
fn capital_a_appends_at_end_of_line() {
let mut v = VimEdit::new("abc".into(), 0);
feed(&mut v, "A");
feed(&mut v, "Z");
assert_eq!(v.text(), "abcZ");
}
#[test]
fn esc_leaves_insert_and_steps_left() {
let mut v = VimEdit::new("abc".into(), 0);
feed(&mut v, "A"); v.handle(VimKey::Esc);
assert_eq!(v.mode(), Mode::Normal);
assert_eq!(v.cursor(), 2); }
#[test]
fn dd_on_single_line_empties_it() {
let mut v = VimEdit::new("hello".into(), 0);
feed(&mut v, "dd");
assert_eq!(v.text(), "");
}
#[test]
fn capital_d_deletes_to_end_of_line() {
let mut v = VimEdit::new("hello world".into(), 6); feed(&mut v, "D");
assert_eq!(v.text(), "hello ");
}
#[test]
fn insert_enter_adds_a_newline() {
let mut v = VimEdit::new("ab".into(), 0);
feed(&mut v, "A"); v.handle(VimKey::Enter);
feed(&mut v, "c");
assert_eq!(v.text(), "ab\nc");
}
#[test]
fn normal_enter_submits() {
let mut v = VimEdit::new("hello".into(), 0);
assert_eq!(v.handle(VimKey::Enter), VimOutcome::Submit);
}
#[test]
fn colon_wq_submits() {
let mut v = VimEdit::new("hello".into(), 0);
assert_eq!(feed(&mut v, ":wq"), VimOutcome::None);
assert_eq!(v.handle(VimKey::Enter), VimOutcome::Submit);
}
#[test]
fn colon_w_submits() {
let mut v = VimEdit::new("hello".into(), 0);
feed(&mut v, ":w");
assert_eq!(v.handle(VimKey::Enter), VimOutcome::Submit);
}
#[test]
fn colon_q_cancels() {
let mut v = VimEdit::new("hello".into(), 0);
feed(&mut v, ":q");
assert_eq!(v.handle(VimKey::Enter), VimOutcome::Cancel);
}
#[test]
fn unknown_command_reports_and_stays_open() {
let mut v = VimEdit::new("hello".into(), 0);
feed(&mut v, ":frobnicate");
assert_eq!(v.handle(VimKey::Enter), VimOutcome::None);
assert_eq!(v.mode(), Mode::Normal);
assert!(v.status_line().contains("Not an editor command"));
}
#[test]
fn command_backspace_past_colon_returns_to_normal() {
let mut v = VimEdit::new("hi".into(), 0);
feed(&mut v, ":w");
v.handle(VimKey::Backspace);
v.handle(VimKey::Backspace); assert_eq!(v.mode(), Mode::Normal);
}
#[test]
fn full_fix_flow_ciw_replace_then_wq() {
let mut v = VimEdit::new("the quick browne fox".into(), 10); feed(&mut v, "ciw");
feed(&mut v, "brown");
assert_eq!(v.text(), "the quick brown fox");
v.handle(VimKey::Esc); assert_eq!(feed(&mut v, ":wq"), VimOutcome::None);
assert_eq!(v.handle(VimKey::Enter), VimOutcome::Submit);
}
#[test]
fn multibyte_word_edit() {
let mut v = VimEdit::new("cafe au lait".into(), 0);
feed(&mut v, "cw"); feed(&mut v, "café");
assert_eq!(v.text(), "café au lait");
}
#[test]
fn open_line_below_enters_insert_on_new_line() {
let mut v = VimEdit::new("abc".into(), 0);
feed(&mut v, "o");
assert_eq!(v.mode(), Mode::Insert);
feed(&mut v, "xy");
assert_eq!(v.text(), "abc\nxy");
}
#[test]
fn j_k_move_between_lines() {
let mut v = VimEdit::new("abc\ndef".into(), 1); feed(&mut v, "j");
assert_eq!(v.cursor(), 5); feed(&mut v, "k");
assert_eq!(v.cursor(), 1); }
#[test]
fn home_and_end_jump_to_line_edges() {
let mut v = VimEdit::new("hello world".into(), 6);
v.handle(VimKey::Home);
assert_eq!(v.cursor(), 0);
v.handle(VimKey::End);
assert_eq!(v.cursor(), 10); feed(&mut v, "i");
v.handle(VimKey::End);
assert_eq!(v.cursor(), 11);
}
#[test]
fn l_reaches_end_of_line_with_virtualedit() {
let mut v = VimEdit::new("ab".into(), 0);
feed(&mut v, "l");
assert_eq!(v.cursor(), 1);
feed(&mut v, "l");
assert_eq!(v.cursor(), 2); feed(&mut v, "l");
assert_eq!(v.cursor(), 2); }
#[test]
fn vertical_motion_preserves_desired_column() {
let mut v = VimEdit::new("hello world\nhi\ngoodbye all".into(), 0);
for _ in 0..8 {
feed(&mut v, "l");
}
assert_eq!(v.cursor(), 8);
feed(&mut v, "j"); assert!((12..=14).contains(&v.cursor()));
feed(&mut v, "j"); assert_eq!(v.cursor(), 15 + 8);
}
#[test]
fn undo_restores_and_redo_reapplies() {
let mut v = VimEdit::new("hello".into(), 0);
feed(&mut v, "x");
assert_eq!(v.text(), "ello");
feed(&mut v, "u");
assert_eq!(v.text(), "hello");
v.handle(VimKey::Redo);
assert_eq!(v.text(), "ello");
}
#[test]
fn an_insert_session_is_one_undo_step() {
let mut v = VimEdit::new("ab".into(), 0);
feed(&mut v, "A"); feed(&mut v, "XYZ");
v.handle(VimKey::Esc);
assert_eq!(v.text(), "abXYZ");
feed(&mut v, "u"); assert_eq!(v.text(), "ab");
}
#[test]
fn ciw_then_undo_restores_the_word() {
let mut v = VimEdit::new("teh quick".into(), 0);
feed(&mut v, "ciw");
feed(&mut v, "the");
v.handle(VimKey::Esc);
assert_eq!(v.text(), "the quick");
feed(&mut v, "u");
assert_eq!(v.text(), "teh quick");
}
#[test]
fn esc_in_normal_cancels() {
let mut v = VimEdit::new("hello".into(), 0);
assert_eq!(v.handle(VimKey::Esc), VimOutcome::Cancel);
}
#[test]
fn esc_aborts_a_pending_operator_without_cancelling() {
let mut v = VimEdit::new("hello world".into(), 0);
feed(&mut v, "d"); assert_eq!(v.handle(VimKey::Esc), VimOutcome::None);
feed(&mut v, "w");
assert_eq!(v.text(), "hello world");
}
#[test]
fn z_equals_requests_spell_suggestions() {
let mut v = VimEdit::new("teh quick".into(), 0);
assert_eq!(v.handle(VimKey::Char('z')), VimOutcome::None);
assert_eq!(v.handle(VimKey::Char('=')), VimOutcome::SpellSuggest);
}
#[test]
fn z_then_other_key_is_a_noop() {
let mut v = VimEdit::new("teh quick".into(), 0);
feed(&mut v, "z");
assert_eq!(v.handle(VimKey::Char('x')), VimOutcome::None);
assert_eq!(v.text(), "teh quick"); }
#[test]
fn replace_range_swaps_a_word_and_is_undoable() {
let mut v = VimEdit::new("teh quick".into(), 0);
v.replace_range(0, 3, "the");
assert_eq!(v.text(), "the quick");
assert_eq!(v.cursor(), 0);
feed(&mut v, "u");
assert_eq!(v.text(), "teh quick");
}
#[test]
fn dot_repeats_the_last_change() {
let mut v = VimEdit::new("aaaa".into(), 0);
feed(&mut v, "x");
feed(&mut v, ".");
feed(&mut v, ".");
assert_eq!(v.text(), "a");
}
#[test]
fn dot_repeats_a_change_with_inserted_text() {
let mut v = VimEdit::new("one two".into(), 0);
feed(&mut v, "ciw");
feed(&mut v, "X");
v.handle(VimKey::Esc);
assert_eq!(v.text(), "X two");
feed(&mut v, "w"); feed(&mut v, "."); assert_eq!(v.text(), "X X");
}
}