From 245fe53044e704ba626ab2f9e87aeee62f041f55 Mon Sep 17 00:00:00 2001 From: Kyler Clay Date: Thu, 5 Jun 2025 03:33:08 -0400 Subject: [PATCH] implemented some more editor tests --- src/prompt/readline/linebuf.rs | 304 +++++++++++++++++++++------------ src/prompt/readline/vimode.rs | 15 +- src/tests/readline.rs | 106 +++++++++++- 3 files changed, 317 insertions(+), 108 deletions(-) diff --git a/src/prompt/readline/linebuf.rs b/src/prompt/readline/linebuf.rs index 4c50cb5..fedd5d5 100644 --- a/src/prompt/readline/linebuf.rs +++ b/src/prompt/readline/linebuf.rs @@ -2,7 +2,7 @@ use std::{ops::{Range, RangeBounds, RangeInclusive}, string::Drain}; use unicode_segmentation::UnicodeSegmentation; -use super::{term::Layout, vicmd::{Direction, Motion, MotionBehavior, RegisterName, To, Verb, ViCmd, Word}}; +use super::{term::Layout, vicmd::{Direction, Motion, MotionBehavior, MotionCmd, RegisterName, To, Verb, ViCmd, Word}}; use crate::{libsh::error::ShResult, prelude::*}; #[derive(PartialEq,Eq,Debug,Clone,Copy)] @@ -15,18 +15,26 @@ pub enum CharClass { impl From<&str> for CharClass { fn from(value: &str) -> Self { - if value.len() > 1 { - return Self::Symbol // Multi-byte grapheme + let mut chars = value.chars(); + + // Empty string fallback + let Some(first) = chars.next() else { + return Self::Other; + }; + + if first.is_alphanumeric() && chars.all(|c| c.is_ascii_punctuation() || c == '\u{0301}' || c == '\u{0308}') { + // Handles things like `ï`, `é`, etc., by manually allowing common diacritics + return CharClass::Alphanum; } if value.chars().all(char::is_alphanumeric) { CharClass::Alphanum } else if value.chars().all(char::is_whitespace) { CharClass::Whitespace - } else if !value.chars().all(char::is_alphanumeric) { + } else if value.chars().all(|c| !c.is_alphanumeric()) { CharClass::Symbol } else { - Self::Other + CharClass::Other } } } @@ -79,6 +87,7 @@ pub enum SelectMode { pub enum MotionKind { To(usize), // Absolute position, exclusive On(usize), // Absolute position, inclusive + Onto(usize), // Absolute position, operations include the position but motions exclude it (wtf vim) Inclusive((usize,usize)), // Range, inclusive Exclusive((usize,usize)), // Range, exclusive Null @@ -552,39 +561,45 @@ impl LineBuf { } } - pub fn dispatch_word_motion(&mut self, to: To, word: Word, dir: Direction) -> usize { + pub fn dispatch_word_motion(&mut self, count: usize, to: To, word: Word, dir: Direction) -> usize { // Not sorry for these method names btw - match to { - To::Start => { - match dir { - Direction::Forward => self.start_of_word_forward_or_end_of_word_backward(word, dir), - Direction::Backward => self.end_of_word_forward_or_start_of_word_backward(word, dir) + let mut pos = ClampedUsize::new(self.cursor.get(), self.cursor.max, false); + for _ in 0..count { + pos.set(match to { + To::Start => { + match dir { + Direction::Forward => self.start_of_word_forward_or_end_of_word_backward_from(pos.get(), word, dir), + Direction::Backward => self.end_of_word_forward_or_start_of_word_backward_from(pos.get(), word, dir) + } } - } - To::End => { - match dir { - Direction::Forward => self.end_of_word_forward_or_start_of_word_backward(word, dir), - Direction::Backward => self.start_of_word_forward_or_end_of_word_backward(word, dir), + To::End => { + match dir { + Direction::Forward => self.end_of_word_forward_or_start_of_word_backward_from(pos.get(), word, dir), + Direction::Backward => self.start_of_word_forward_or_end_of_word_backward_from(pos.get(), word, dir), + } } - } + }); } + pos.get() } /// Finds the start of a word forward, or the end of a word backward /// /// Finding the start of a word in the forward direction, and finding the end of a word in the backward direction /// are logically the same operation, if you use a reversed iterator for the backward motion. - pub fn start_of_word_forward_or_end_of_word_backward(&mut self, word: Word, dir: Direction) -> usize { - let mut pos = self.cursor.get(); + pub fn start_of_word_forward_or_end_of_word_backward_from(&mut self, mut pos: usize, word: Word, dir: Direction) -> usize { let default = match dir { Direction::Backward => 0, Direction::Forward => self.grapheme_indices().len() }; - let mut indices_iter = self.directional_indices_iter(dir).peekable(); // And make it peekable + let mut indices_iter = self.directional_indices_iter_from(pos,dir).peekable(); // And make it peekable match word { Word::Big => { - let on_boundary = self.grapheme_at(pos).is_none_or(is_whitespace); + let Some(next) = indices_iter.peek() else { + return default + }; + let on_boundary = self.grapheme_at(*next).is_none_or(is_whitespace); flog!(DEBUG,on_boundary); flog!(DEBUG,pos); if on_boundary { @@ -613,16 +628,18 @@ impl LineBuf { } Word::Normal => { let Some(cur_char) = self.grapheme_at(pos).map(|c| c.to_string()) else { return default }; - let Some(next_idx) = indices_iter.next() else { return default }; - let on_boundary = self.grapheme_at(next_idx).is_none_or(|c| is_other_class_or_is_ws(c, &cur_char)); + let Some(next_idx) = indices_iter.peek() else { return default }; + let on_boundary = !is_whitespace(&cur_char) && self.grapheme_at(*next_idx).is_none_or(|c| is_other_class_or_is_ws(c, &cur_char)); + flog!(DEBUG,on_boundary); if on_boundary { - pos = next_idx + pos = *next_idx } let Some(cur_char) = self.grapheme_at(pos).map(|c| c.to_string()) else { return default }; let on_whitespace = is_whitespace(&cur_char); + flog!(DEBUG,on_whitespace); // Advance until hitting whitespace or a different character class if !on_whitespace { @@ -648,6 +665,7 @@ impl LineBuf { .is_some_and(|c| !is_whitespace(c)) } ).unwrap_or(default); + flog!(DEBUG,self.grapheme_at(non_ws_pos)); non_ws_pos } } @@ -656,24 +674,22 @@ impl LineBuf { /// /// Finding the end of a word in the forward direction, and finding the start of a word in the backward direction /// are logically the same operation, if you use a reversed iterator for the backward motion. - pub fn end_of_word_forward_or_start_of_word_backward(&mut self, word: Word, dir: Direction) -> usize { - let mut pos = self.cursor.get(); + pub fn end_of_word_forward_or_start_of_word_backward_from(&mut self, mut pos: usize, word: Word, dir: Direction) -> usize { let default = match dir { Direction::Backward => 0, Direction::Forward => self.grapheme_indices().len() }; - let mut indices_iter = self.directional_indices_iter(dir).peekable(); + let mut indices_iter = self.directional_indices_iter_from(pos,dir).peekable(); match word { Word::Big => { - let Some(next_idx) = indices_iter.next() else { return default }; - let on_boundary = self.grapheme_at(next_idx).is_none_or(is_whitespace); + let Some(next_idx) = indices_iter.peek() else { return default }; + let on_boundary = self.grapheme_at(*next_idx).is_none_or(is_whitespace); if on_boundary { let Some(idx) = indices_iter.next() else { return default }; pos = idx; } - // Check current grapheme let Some(cur_char) = self.grapheme_at(pos).map(|c| c.to_string()) else { return default @@ -706,9 +722,10 @@ impl LineBuf { } Word::Normal => { let Some(cur_char) = self.grapheme_at(pos).map(|c| c.to_string()) else { return default }; - let Some(next_idx) = indices_iter.next() else { return default }; - let on_boundary = self.grapheme_at(next_idx).is_none_or(|c| is_other_class_or_is_ws(c, &cur_char)); + let Some(next_idx) = indices_iter.peek() else { return default }; + let on_boundary = !is_whitespace(&cur_char) && self.grapheme_at(*next_idx).is_none_or(|c| is_other_class_or_is_ws(c, &cur_char)); if on_boundary { + let next_idx = indices_iter.next().unwrap(); pos = next_idx } @@ -800,7 +817,7 @@ impl LineBuf { pub fn find bool>(&mut self, op: F) -> usize { self.find_from(self.cursor.get(), op) } - pub fn eval_motion(&mut self, motion: Motion) -> MotionKind { + pub fn eval_motion(&mut self, motion: MotionCmd) -> MotionKind { let buffer = self.buffer.clone(); if self.has_hint() { let hint = self.hint.clone().unwrap(); @@ -808,14 +825,35 @@ impl LineBuf { } let eval = match motion { - Motion::WholeLine => { - let (start,end) = self.this_line(); + MotionCmd(count,Motion::WholeLine) => { + let start = self.start_of_cursor_line(); + let end = self.find_newlines(count); MotionKind::Inclusive((start,end)) } - Motion::WordMotion(to, word, dir) => MotionKind::On(self.dispatch_word_motion(to, word, dir)), - Motion::TextObj(text_obj, bound) => todo!(), - Motion::EndOfLastWord => { + MotionCmd(count,Motion::WordMotion(to, word, dir)) => { + let pos = self.dispatch_word_motion(count, to, word, dir); + let mut pos = ClampedUsize::new(pos,self.cursor.max,false); + // End-based operations must include the last character + // But the cursor must also stop just before it when moving + // So we have to do some weird shit to reconcile this behavior + if to == To::End { + match dir { + Direction::Forward => { + MotionKind::Onto(pos.get()) + } + Direction::Backward => { + let (start,end) = ordered(self.cursor.get(),pos.get()); + MotionKind::Inclusive((start,end)) + } + } + } else { + MotionKind::On(pos.get()) + } + } + MotionCmd(count,Motion::TextObj(text_obj, bound)) => todo!(), + MotionCmd(count,Motion::EndOfLastWord) => { let start = self.start_of_cursor_line(); + let mut newline_count = 0; let mut indices = self.directional_indices_iter_from(start,Direction::Forward); let mut last_graphical = None; while let Some(idx) = indices.next() { @@ -824,7 +862,10 @@ impl LineBuf { last_graphical = Some(idx); } if grapheme == "\n" { - break + newline_count += 1; + if newline_count == count { + break + } } } let Some(last) = last_graphical else { @@ -832,7 +873,7 @@ impl LineBuf { }; MotionKind::On(last) } - Motion::BeginningOfFirstWord => { + MotionCmd(_,Motion::BeginningOfFirstWord) => { let start = self.start_of_cursor_line(); let mut indices = self.directional_indices_iter_from(start,Direction::Forward); let mut first_graphical = None; @@ -852,35 +893,38 @@ impl LineBuf { }; MotionKind::On(first) } - Motion::BeginningOfLine => MotionKind::On(self.start_of_cursor_line()), - Motion::EndOfLine => MotionKind::On(self.end_of_cursor_line()), - Motion::CharSearch(direction, dest, ch) => todo!(), - Motion::BackwardChar => MotionKind::On(self.cursor.ret_sub(1)), - Motion::ForwardChar => MotionKind::On(self.cursor.ret_add_inclusive(1)), - Motion::LineUp => todo!(), - Motion::LineUpCharwise => todo!(), - Motion::ScreenLineUp => todo!(), - Motion::ScreenLineUpCharwise => todo!(), - Motion::LineDown => todo!(), - Motion::LineDownCharwise => todo!(), - Motion::ScreenLineDown => todo!(), - Motion::ScreenLineDownCharwise => todo!(), - Motion::BeginningOfScreenLine => todo!(), - Motion::FirstGraphicalOnScreenLine => todo!(), - Motion::HalfOfScreen => todo!(), - Motion::HalfOfScreenLineText => todo!(), - Motion::WholeBuffer => todo!(), - Motion::BeginningOfBuffer => todo!(), - Motion::EndOfBuffer => todo!(), - Motion::ToColumn(col) => todo!(), - Motion::ToDelimMatch => todo!(), - Motion::ToBrace(direction) => todo!(), - Motion::ToBracket(direction) => todo!(), - Motion::ToParen(direction) => todo!(), - Motion::Range(start, end) => todo!(), - Motion::RepeatMotion => todo!(), - Motion::RepeatMotionRev => todo!(), - Motion::Null => MotionKind::Null + MotionCmd(_,Motion::BeginningOfLine) => MotionKind::On(self.start_of_cursor_line()), + MotionCmd(count,Motion::EndOfLine) => { + let pos = self.find_newlines(count); + MotionKind::On(pos) + } + MotionCmd(count,Motion::CharSearch(direction, dest, ch)) => todo!(), + MotionCmd(count,Motion::BackwardChar) => MotionKind::On(self.cursor.ret_sub(1)), + MotionCmd(count,Motion::ForwardChar) => MotionKind::On(self.cursor.ret_add_inclusive(1)), + MotionCmd(count,Motion::LineUp) => todo!(), + MotionCmd(count,Motion::LineUpCharwise) => todo!(), + MotionCmd(count,Motion::ScreenLineUp) => todo!(), + MotionCmd(count,Motion::ScreenLineUpCharwise) => todo!(), + MotionCmd(count,Motion::LineDown) => todo!(), + MotionCmd(count,Motion::LineDownCharwise) => todo!(), + MotionCmd(count,Motion::ScreenLineDown) => todo!(), + MotionCmd(count,Motion::ScreenLineDownCharwise) => todo!(), + MotionCmd(count,Motion::BeginningOfScreenLine) => todo!(), + MotionCmd(count,Motion::FirstGraphicalOnScreenLine) => todo!(), + MotionCmd(count,Motion::HalfOfScreen) => todo!(), + MotionCmd(count,Motion::HalfOfScreenLineText) => todo!(), + MotionCmd(count,Motion::WholeBuffer) => todo!(), + MotionCmd(count,Motion::BeginningOfBuffer) => todo!(), + MotionCmd(count,Motion::EndOfBuffer) => todo!(), + MotionCmd(count,Motion::ToColumn(col)) => todo!(), + MotionCmd(count,Motion::ToDelimMatch) => todo!(), + MotionCmd(count,Motion::ToBrace(direction)) => todo!(), + MotionCmd(count,Motion::ToBracket(direction)) => todo!(), + MotionCmd(count,Motion::ToParen(direction)) => todo!(), + MotionCmd(count,Motion::Range(start, end)) => todo!(), + MotionCmd(count,Motion::RepeatMotion) => todo!(), + MotionCmd(count,Motion::RepeatMotionRev) => todo!(), + MotionCmd(count,Motion::Null) => MotionKind::Null }; self.set_buffer(buffer); @@ -925,6 +969,7 @@ impl LineBuf { } pub fn move_cursor(&mut self, motion: MotionKind) { match motion { + MotionKind::Onto(pos) | // Onto follows On's behavior for cursor movements MotionKind::On(pos) => self.cursor.set(pos), MotionKind::To(pos) => { self.cursor.set(pos); @@ -946,28 +991,48 @@ impl LineBuf { MotionKind::Null => { /* Do nothing */ } } } + pub fn range_from_motion(&mut self, motion: &MotionKind) -> Option<(usize,usize)> { + let range = match motion { + MotionKind::On(pos) => ordered(self.cursor.get(), *pos), + MotionKind::Onto(pos) => { + // For motions which include the character at the cursor during operations + // but exclude the character during movements + let mut pos = ClampedUsize::new(*pos, self.cursor.max, false); + let mut cursor_pos = self.cursor; + + // The end of the range must be incremented by one + match pos.get().cmp(&self.cursor.get()) { + std::cmp::Ordering::Less => cursor_pos.add(1), + std::cmp::Ordering::Greater => pos.add(1), + std::cmp::Ordering::Equal => {} + } + ordered(cursor_pos.get(),pos.get()) + } + MotionKind::To(pos) => { + let pos = match pos.cmp(&self.cursor.get()) { + std::cmp::Ordering::Less => *pos + 1, + std::cmp::Ordering::Greater => *pos - 1, + std::cmp::Ordering::Equal => *pos, + }; + ordered(self.cursor.get(), pos) + } + MotionKind::Inclusive((start,end)) => { + let (start, mut end) = ordered(*start, *end); + end = ClampedUsize::new(end, self.cursor.max, false).ret_add(1); + (start,end) + } + MotionKind::Exclusive((start,end)) => ordered(*start, *end), + MotionKind::Null => return None + }; + Some(range) + } pub fn exec_verb(&mut self, verb: Verb, motion: MotionKind, register: RegisterName) -> ShResult<()> { match verb { Verb::Delete | Verb::Yank | Verb::Change => { - let (start,end) = match motion { - MotionKind::On(pos) => ordered(self.cursor.get(), pos), - MotionKind::To(pos) => { - let pos = match pos.cmp(&self.cursor.get()) { - std::cmp::Ordering::Less => pos + 1, - std::cmp::Ordering::Greater => pos - 1, - std::cmp::Ordering::Equal => pos, - }; - ordered(self.cursor.get(), pos) - } - MotionKind::Inclusive((start,end)) => { - let (start, mut end) = ordered(start, end); - end = ClampedUsize::new(end, self.cursor.max, false).ret_add(1); - (start,end) - } - MotionKind::Exclusive((start,end)) => ordered(start, end), - MotionKind::Null => return Ok(()) + let Some((start,end)) = self.range_from_motion(&motion) else { + return Ok(()) }; flog!(DEBUG,start,end); let register_text = if verb == Verb::Yank { @@ -980,7 +1045,20 @@ impl LineBuf { register.write_to_register(register_text); self.cursor.set(start); } - Verb::Rot13 => todo!(), + Verb::Rot13 => { + flog!(DEBUG,motion); + let Some((start,end)) = self.range_from_motion(&motion) else { + return Ok(()) + }; + flog!(DEBUG,start,end); + let slice = self.slice(start..end) + .unwrap_or_default(); + flog!(DEBUG,slice); + let rot13 = rot13(slice); + flog!(DEBUG,rot13); + self.buffer.replace_range(start..end, &rot13); + self.cursor.set(start); + } Verb::ReplaceChar(_) => todo!(), Verb::ToggleCase => todo!(), Verb::ToLower => todo!(), @@ -1050,26 +1128,24 @@ impl LineBuf { let cursor_pos = self.cursor.get(); for _ in 0..verb_count.unwrap_or(1) { - for _ in 0..motion_count.unwrap_or(1) { - /* - * Let's evaluate the motion now - * If motion is None, we will try to use self.select_range - * If self.select_range is None, we will use MotionKind::Null - */ - let motion_eval = motion - .clone() - .map(|m| self.eval_motion(m.1)) - .unwrap_or({ - self.select_range - .map(MotionKind::Inclusive) - .unwrap_or(MotionKind::Null) - }); + /* + * Let's evaluate the motion now + * If motion is None, we will try to use self.select_range + * If self.select_range is None, we will use MotionKind::Null + */ + let motion_eval = motion + .clone() + .map(|m| self.eval_motion(m)) + .unwrap_or({ + self.select_range + .map(MotionKind::Inclusive) + .unwrap_or(MotionKind::Null) + }); - if let Some(verb) = verb.clone() { - self.exec_verb(verb.1, motion_eval, register)?; - } else { - self.apply_motion(motion_eval); - } + if let Some(verb) = verb.clone() { + self.exec_verb(verb.1, motion_eval, register)?; + } else { + self.apply_motion(motion_eval); } } @@ -1106,6 +1182,22 @@ impl LineBuf { } } +/// Rotate alphabetic characters by 13 alphabetic positions +pub fn rot13(input: &str) -> String { + input.chars() + .map(|c| { + if c.is_ascii_lowercase() { + let offset = b'a'; + (((c as u8 - offset + 13) % 26) + offset) as char + } else if c.is_ascii_uppercase() { + let offset = b'A'; + (((c as u8 - offset + 13) % 26) + offset) as char + } else { + c + } + }).collect() +} + pub fn ordered(start: usize, end: usize) -> (usize,usize) { if start > end { (end,start) diff --git a/src/prompt/readline/vimode.rs b/src/prompt/readline/vimode.rs index d71b389..c5b52e8 100644 --- a/src/prompt/readline/vimode.rs +++ b/src/prompt/readline/vimode.rs @@ -2,8 +2,9 @@ use std::iter::Peekable; use std::str::Chars; use nix::NixPath; +use unicode_segmentation::UnicodeSegmentation; -use super::keys::{KeyEvent as E, KeyCode as K, ModKeys as M}; +use super::keys::{KeyCode as K, KeyEvent as E, ModKeys as M}; use super::vicmd::{Anchor, Bound, Dest, Direction, Motion, MotionCmd, RegisterName, TextObj, To, Verb, VerbCmd, ViCmd, Word}; use crate::prelude::*; @@ -51,6 +52,17 @@ pub trait ViMode { fn clamp_cursor(&self) -> bool; fn hist_scroll_start_pos(&self) -> Option; fn report_mode(&self) -> ModeReport; + fn cmds_from_raw(&mut self, raw: &str) -> Vec { + let mut cmds = vec![]; + for ch in raw.graphemes(true) { + let key = E::new(ch, M::NONE); + let Some(cmd) = self.handle_key(key) else { + continue + }; + cmds.push(cmd) + } + cmds + } } #[derive(Default,Debug)] @@ -136,6 +148,7 @@ impl ViMode for ViInsert { } } + fn is_repeatable(&self) -> bool { true } diff --git a/src/tests/readline.rs b/src/tests/readline.rs index 5a86a51..5239161 100644 --- a/src/tests/readline.rs +++ b/src/tests/readline.rs @@ -1,7 +1,34 @@ -use crate::prompt::readline::linebuf::LineBuf; +use crate::prompt::readline::{linebuf::LineBuf, vimode::{ViInsert, ViMode, ViNormal}}; use super::super::*; +fn assert_normal_cmd(cmd: &str, start: &str, cursor: usize, expected_buf: &str, expected_cursor: usize) { + let cmd = ViNormal::new() + .cmds_from_raw(cmd) + .pop() + .unwrap(); + let mut buf = LineBuf::new().with_initial(start, cursor); + buf.exec_cmd(cmd).unwrap(); + assert_eq!(buf.as_str(),expected_buf); + assert_eq!(buf.cursor.get(),expected_cursor); +} + +#[test] +fn vimode_insert_cmds() { + let raw = "abcdefghijklmnopqrstuvwxyz1234567890-=[];'<>/\\x1b"; + let mut mode = ViInsert::new(); + let cmds = mode.cmds_from_raw(raw); + insta::assert_debug_snapshot!(cmds) +} + +#[test] +fn vimode_normal_cmds() { + let raw = "d2wg?5b2P5x"; + let mut mode = ViNormal::new(); + let cmds = mode.cmds_from_raw(raw); + insta::assert_debug_snapshot!(cmds) +} + #[test] fn linebuf_empty_linebuf() { let mut buf = LineBuf::new(); @@ -129,3 +156,80 @@ fn linebuf_cursor_motion() { ); } } + +#[test] +fn editor_delete_word() { + assert_normal_cmd( + "dw", + "The quick brown fox jumps over the lazy dog", + 16, + "The quick brown jumps over the lazy dog", + 16 + ); +} + +#[test] +fn editor_delete_backwards() { + assert_normal_cmd( + "2db", + "The quick brown fox jumps over the lazy dog", + 16, + "The fox jumps over the lazy dog", + 4 + ); +} + +#[test] +fn editor_rot13_five_words_backwards() { + assert_normal_cmd( + "g?5b", + "The quick brown fox jumps over the lazy dog", + 31, + "The dhvpx oebja sbk whzcf bire the lazy dog", + 4 + ); +} + +#[test] +fn editor_delete_word_on_whitespace() { + assert_normal_cmd( + "dw", + "The quick brown fox", + 10, // on the whitespace between "quick" and "brown" + "The quick brown fox", + 10 + ); +} + +#[test] +fn editor_delete_5_words() { + assert_normal_cmd( + "5dw", + "The quick brown fox jumps over the lazy dog", + 16, + "The quick brown dog", + 16 + ); +} + +#[test] +fn editor_delete_end_includes_last() { + assert_normal_cmd( + "de", + "The quick brown fox::::jumps over the lazy dog", + 16, + "The quick brown ::::jumps over the lazy dog", + 16 + ); +} + +#[test] +fn editor_delete_end_unicode_word() { + assert_normal_cmd( + "de", + "naïve café world", + 0, + " café world", // deletes "naïve" + 0 + ); +}