diff --git a/src/edit_mode/hx/command.rs b/src/edit_mode/hx/command.rs new file mode 100644 index 00000000..6c30b3c8 --- /dev/null +++ b/src/edit_mode/hx/command.rs @@ -0,0 +1,239 @@ +use super::{motion::HxCharSearch, motion::Motion, parser::ReedlineOption}; +use crate::{EditCommand, Hx, ReedlineEvent}; +use std::iter::Peekable; + +pub fn parse_command<'iter, I>(input: &mut Peekable) -> Option +where + I: Iterator, +{ + match input.peek() { + Some('d') => { + let _ = input.next(); + Some(Command::Delete) + } + Some('p') => { + let _ = input.next(); + Some(Command::PasteAfter) + } + Some('P') => { + let _ = input.next(); + Some(Command::PasteBefore) + } + Some('i') => { + let _ = input.next(); + Some(Command::EnterHxInsert) + } + Some('a') => { + let _ = input.next(); + Some(Command::EnterHxAppend) + } + Some('u') => { + let _ = input.next(); + Some(Command::Undo) + } + Some('U') => { + let _ = input.next(); + Some(Command::Redo) + } + Some('c') => { + let _ = input.next(); + Some(Command::Change) + } + Some('x') => { + let _ = input.next(); + Some(Command::SelectLine) + } + Some('?') => { + let _ = input.next(); + Some(Command::HistorySearch) + } + Some('I') => { + let _ = input.next(); + Some(Command::PrependToStart) + } + Some('A') => { + let _ = input.next(); + Some(Command::AppendToEnd) + } + Some('~') => { + let _ = input.next(); + Some(Command::Switchcase) + } + Some('-') => { + let _ = input.next(); + Some(Command::TrimSelection) + } + Some('.') => { + let _ = input.next(); + Some(Command::RepeatLastInsertion) + } + _ => None, + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Command { + Incomplete, + Delete, + DeleteChar, + ReplaceChar(char), + PasteAfter, + PasteBefore, + EnterHxAppend, + EnterHxInsert, + Undo, + Redo, + SelectLine, + TrimSelection, + AppendToEnd, + PrependToStart, + Change, + HistorySearch, + Switchcase, + RepeatLastInsertion, +} + +impl Command { + pub fn whole_line_char(&self) -> Option { + match self { + Command::Delete => Some('d'), + Command::Change => Some('c'), + _ => None, + } + } + + pub fn requires_motion(&self) -> bool { + matches!(self, Command::Delete | Command::Change) + } + + pub fn to_reedline(&self, hx_state: &mut Hx) -> Vec { + match self { + Self::EnterHxInsert => vec![ReedlineOption::Event(ReedlineEvent::Repaint)], + Self::EnterHxAppend => vec![ReedlineOption::Edit(EditCommand::MoveRight)], + Self::PasteAfter => vec![ReedlineOption::Edit(EditCommand::PasteCutBufferAfter)], + Self::PasteBefore => vec![ReedlineOption::Edit(EditCommand::PasteCutBufferBefore)], + Self::Undo => vec![ReedlineOption::Edit(EditCommand::Undo)], + Self::AppendToEnd => vec![ReedlineOption::Edit(EditCommand::MoveToLineEnd)], + Self::PrependToStart => vec![ReedlineOption::Edit(EditCommand::MoveToLineStart)], + Self::DeleteChar => vec![ReedlineOption::Edit(EditCommand::CutChar)], + Self::ReplaceChar(c) => { + vec![ReedlineOption::Edit(EditCommand::ReplaceChar(*c))] + } + Self::HistorySearch => vec![ReedlineOption::Event(ReedlineEvent::SearchHistory)], + Self::Switchcase => vec![ReedlineOption::Edit(EditCommand::SwitchcaseChar)], + // Mark a command as incomplete whenever a motion is required to finish the command + Self::Delete | Self::Change | Self::Incomplete => vec![ReedlineOption::Incomplete], + Self::RepeatLastInsertion => match &hx_state.previous { + Some(event) => vec![ReedlineOption::Event(event.clone())], + None => vec![], + }, + Self::Redo => todo!(), + Self::SelectLine => todo!(), + Self::TrimSelection => todo!(), + } + } + + pub fn to_reedline_with_motion( + &self, + motion: &Motion, + hx_state: &mut Hx, + ) -> Option> { + match self { + Self::Delete => match motion { + Motion::End => Some(vec![ReedlineOption::Edit(EditCommand::CutToLineEnd)]), + Motion::Line => Some(vec![ReedlineOption::Edit(EditCommand::CutCurrentLine)]), + Motion::NextWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordRightToNext)]) + } + Motion::NextBigWord => Some(vec![ReedlineOption::Edit( + EditCommand::CutBigWordRightToNext, + )]), + Motion::NextWordEnd => Some(vec![ReedlineOption::Edit(EditCommand::CutWordRight)]), + Motion::NextBigWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRight)]) + } + Motion::PreviousWord => Some(vec![ReedlineOption::Edit(EditCommand::CutWordLeft)]), + Motion::PreviousBigWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordLeft)]) + } + Motion::RightUntil(c) => { + hx_state.last_char_search = Some(HxCharSearch::ToRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutRightUntil(*c))]) + } + Motion::RightBefore(c) => { + hx_state.last_char_search = Some(HxCharSearch::TillRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutRightBefore(*c))]) + } + Motion::LeftUntil(c) => { + hx_state.last_char_search = Some(HxCharSearch::ToLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutLeftUntil(*c))]) + } + Motion::LeftBefore(c) => { + hx_state.last_char_search = Some(HxCharSearch::TillLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutLeftBefore(*c))]) + } + Motion::Start => Some(vec![ReedlineOption::Edit(EditCommand::CutFromLineStart)]), + Motion::Left => Some(vec![ReedlineOption::Edit(EditCommand::Backspace)]), + Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::Delete)]), + Motion::Up => None, + Motion::Down => None, + }, + Self::Change => { + let op = match motion { + Motion::End => Some(vec![ReedlineOption::Edit(EditCommand::ClearToLineEnd)]), + Motion::Line => Some(vec![ + ReedlineOption::Edit(EditCommand::MoveToStart), + ReedlineOption::Edit(EditCommand::ClearToLineEnd), + ]), + Motion::NextWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordRightToNext)]) + } + Motion::NextBigWord => Some(vec![ReedlineOption::Edit( + EditCommand::CutBigWordRightToNext, + )]), + Motion::NextWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordRight)]) + } + Motion::NextBigWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRight)]) + } + Motion::PreviousWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordLeft)]) + } + Motion::PreviousBigWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordLeft)]) + } + Motion::RightUntil(c) => { + hx_state.last_char_search = Some(HxCharSearch::ToRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutRightUntil(*c))]) + } + Motion::RightBefore(c) => { + hx_state.last_char_search = Some(HxCharSearch::TillRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutRightBefore(*c))]) + } + Motion::LeftUntil(c) => { + hx_state.last_char_search = Some(HxCharSearch::ToLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutLeftUntil(*c))]) + } + Motion::LeftBefore(c) => { + hx_state.last_char_search = Some(HxCharSearch::TillLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutLeftBefore(*c))]) + } + Motion::Start => { + Some(vec![ReedlineOption::Edit(EditCommand::CutFromLineStart)]) + } + Motion::Left => Some(vec![ReedlineOption::Edit(EditCommand::Backspace)]), + Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::Delete)]), + Motion::Up => None, + Motion::Down => None, + }; + // Semihack: Append `Repaint` to ensure the mode change gets displayed + op.map(|mut vec| { + vec.push(ReedlineOption::Event(ReedlineEvent::Repaint)); + vec + }) + } + _ => None, + } + } +} diff --git a/src/edit_mode/hx/hx_keybindings.rs b/src/edit_mode/hx/hx_keybindings.rs new file mode 100644 index 00000000..ad9deb90 --- /dev/null +++ b/src/edit_mode/hx/hx_keybindings.rs @@ -0,0 +1,26 @@ +use crate::edit_mode::{ + keybindings::{ + add_common_control_bindings, add_common_edit_bindings, add_common_navigation_bindings, + }, + Keybindings, +}; + +/// Default Hx normal keybindings +pub fn default_hx_normal_keybindings() -> Keybindings { + let mut kb = Keybindings::new(); + + add_common_control_bindings(&mut kb); + add_common_navigation_bindings(&mut kb); + kb +} + +/// Default Hx insert keybindings +pub fn default_hx_insert_keybindings() -> Keybindings { + let mut kb = Keybindings::new(); + + add_common_control_bindings(&mut kb); + add_common_navigation_bindings(&mut kb); + add_common_edit_bindings(&mut kb); + + kb +} diff --git a/src/edit_mode/hx/mod.rs b/src/edit_mode/hx/mod.rs new file mode 100644 index 00000000..07f39a20 --- /dev/null +++ b/src/edit_mode/hx/mod.rs @@ -0,0 +1,267 @@ +mod command; +mod hx_keybindings; +mod motion; +mod parser; + +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +pub use hx_keybindings::{default_hx_insert_keybindings, default_hx_normal_keybindings}; + +use self::motion::HxCharSearch; + +use super::EditMode; +use crate::{ + edit_mode::{hx::parser::parse, keybindings::Keybindings}, + enums::{EditCommand, ReedlineEvent, ReedlineRawEvent}, + prompt::PromptHxMode, + PromptEditMode, +}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum HxMode { + Normal, + Insert, +} + +/// This parses incoming input `Event`s like the helix editor +pub struct Hx { + cache: Vec, + insert_keybindings: Keybindings, + normal_keybindings: Keybindings, + mode: HxMode, + previous: Option, + last_char_search: Option, +} + +impl Default for Hx { + fn default() -> Self { + Hx { + insert_keybindings: default_hx_insert_keybindings(), + normal_keybindings: default_hx_normal_keybindings(), + cache: Vec::new(), + mode: HxMode::Insert, + previous: None, + last_char_search: None, + } + } +} + +impl Hx { + /// Creates hx editor using defined keybindings + pub fn new(insert_keybindings: Keybindings, normal_keybindings: Keybindings) -> Self { + Self { + insert_keybindings, + normal_keybindings, + ..Default::default() + } + } +} + +impl EditMode for Hx { + fn parse_event(&mut self, event: ReedlineRawEvent) -> ReedlineEvent { + match event.into() { + Event::Key(KeyEvent { + code, modifiers, .. + }) => match (self.mode, modifiers, code) { + (HxMode::Normal, modifier, KeyCode::Char(c)) => { + let c = c.to_ascii_lowercase(); + + if let Some(event) = self + .normal_keybindings + .find_binding(modifiers, KeyCode::Char(c)) + { + event + } else if modifier == KeyModifiers::NONE || modifier == KeyModifiers::SHIFT { + self.cache.push(if modifier == KeyModifiers::SHIFT { + c.to_ascii_uppercase() + } else { + c + }); + + let res = parse(&mut self.cache.iter().peekable()); + + if !res.is_valid() { + self.cache.clear(); + ReedlineEvent::None + } else if res.is_complete() { + if res.enters_insert_mode() { + self.mode = HxMode::Insert; + } + + let event = res.to_reedline_event(self); + self.cache.clear(); + event + } else { + ReedlineEvent::None + } + } else { + ReedlineEvent::None + } + } + (HxMode::Insert, modifier, KeyCode::Char(c)) => { + // Note. The modifier can also be a combination of modifiers, for + // example: + // KeyModifiers::CONTROL | KeyModifiers::ALT + // KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT + // + // Mixed modifiers are used by non american keyboards that have extra + // keys like 'alt gr'. Keep this in mind if in the future there are + // cases where an event is not being captured + let c = match modifier { + KeyModifiers::NONE => c, + _ => c.to_ascii_lowercase(), + }; + + self.insert_keybindings + .find_binding(modifier, KeyCode::Char(c)) + .unwrap_or_else(|| { + if modifier == KeyModifiers::NONE + || modifier == KeyModifiers::SHIFT + || modifier == KeyModifiers::CONTROL | KeyModifiers::ALT + || modifier + == KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SHIFT + { + ReedlineEvent::Edit(vec![EditCommand::InsertChar( + if modifier == KeyModifiers::SHIFT { + c.to_ascii_uppercase() + } else { + c + }, + )]) + } else { + ReedlineEvent::None + } + }) + } + (_, KeyModifiers::NONE, KeyCode::Esc) => { + self.cache.clear(); + self.mode = HxMode::Normal; + ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) + } + (_, KeyModifiers::NONE, KeyCode::Enter) => { + self.mode = HxMode::Insert; + ReedlineEvent::Enter + } + (HxMode::Normal, _, _) => self + .normal_keybindings + .find_binding(modifiers, code) + .unwrap_or(ReedlineEvent::None), + (HxMode::Insert, _, _) => self + .insert_keybindings + .find_binding(modifiers, code) + .unwrap_or(ReedlineEvent::None), + }, + + Event::Mouse(_) => ReedlineEvent::Mouse, + Event::Resize(width, height) => ReedlineEvent::Resize(width, height), + Event::FocusGained => ReedlineEvent::None, + Event::FocusLost => ReedlineEvent::None, + Event::Paste(body) => ReedlineEvent::Edit(vec![EditCommand::InsertString( + body.replace("\r\n", "\n").replace('\r', "\n"), + )]), + } + } + + fn edit_mode(&self) -> PromptEditMode { + match self.mode { + HxMode::Normal => PromptEditMode::Hx(PromptHxMode::Normal), + HxMode::Insert => PromptEditMode::Hx(PromptHxMode::Insert), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn esc_leads_to_normal_mode_test() { + let mut hx = Hx::default(); + let esc = ReedlineRawEvent::convert_from(Event::Key(KeyEvent::new( + KeyCode::Esc, + KeyModifiers::NONE, + ))) + .unwrap(); + let result = hx.parse_event(esc); + + assert_eq!( + result, + ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) + ); + assert!(matches!(hx.mode, HxMode::Normal)); + } + + #[test] + fn keybinding_without_modifier_test() { + let mut keybindings = default_hx_normal_keybindings(); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('e'), + ReedlineEvent::ClearScreen, + ); + + let mut vi = Hx { + insert_keybindings: default_hx_insert_keybindings(), + normal_keybindings: keybindings, + mode: HxMode::Normal, + ..Default::default() + }; + + let esc = ReedlineRawEvent::convert_from(Event::Key(KeyEvent::new( + KeyCode::Char('e'), + KeyModifiers::NONE, + ))) + .unwrap(); + let result = vi.parse_event(esc); + + assert_eq!(result, ReedlineEvent::ClearScreen); + } + + #[test] + fn keybinding_with_shift_modifier_test() { + let mut keybindings = default_hx_normal_keybindings(); + keybindings.add_binding( + KeyModifiers::SHIFT, + KeyCode::Char('$'), + ReedlineEvent::CtrlD, + ); + + let mut hx = Hx { + insert_keybindings: default_hx_insert_keybindings(), + normal_keybindings: keybindings, + mode: HxMode::Normal, + ..Default::default() + }; + + let esc = ReedlineRawEvent::convert_from(Event::Key(KeyEvent::new( + KeyCode::Char('$'), + KeyModifiers::SHIFT, + ))) + .unwrap(); + let result = hx.parse_event(esc); + + assert_eq!(result, ReedlineEvent::CtrlD); + } + + #[test] + fn non_register_modifier_test() { + let keybindings = default_hx_normal_keybindings(); + let mut hx = Hx { + insert_keybindings: default_hx_insert_keybindings(), + normal_keybindings: keybindings, + mode: HxMode::Normal, + ..Default::default() + }; + + let esc = ReedlineRawEvent::convert_from(Event::Key(KeyEvent::new( + KeyCode::Char('q'), + KeyModifiers::NONE, + ))) + .unwrap(); + let result = hx.parse_event(esc); + + assert_eq!(result, ReedlineEvent::None); + } +} diff --git a/src/edit_mode/hx/motion.rs b/src/edit_mode/hx/motion.rs new file mode 100644 index 00000000..3b425fa9 --- /dev/null +++ b/src/edit_mode/hx/motion.rs @@ -0,0 +1,215 @@ +use std::iter::Peekable; + +use crate::{EditCommand, Hx, ReedlineEvent}; + +use super::parser::{ParseResult, ReedlineOption}; + +pub fn parse_motion<'iter, I>( + input: &mut Peekable, + command_char: Option, +) -> ParseResult +where + I: Iterator, +{ + match input.peek() { + Some('h') => { + let _ = input.next(); + ParseResult::Valid(Motion::Left) + } + Some('l') => { + let _ = input.next(); + ParseResult::Valid(Motion::Right) + } + Some('j') => { + let _ = input.next(); + ParseResult::Valid(Motion::Down) + } + Some('k') => { + let _ = input.next(); + ParseResult::Valid(Motion::Up) + } + Some('b') => { + let _ = input.next(); + ParseResult::Valid(Motion::PreviousWord) + } + Some('B') => { + let _ = input.next(); + ParseResult::Valid(Motion::PreviousBigWord) + } + Some('w') => { + let _ = input.next(); + ParseResult::Valid(Motion::NextWord) + } + Some('W') => { + let _ = input.next(); + ParseResult::Valid(Motion::NextBigWord) + } + Some('e') => { + let _ = input.next(); + ParseResult::Valid(Motion::NextWordEnd) + } + Some('E') => { + let _ = input.next(); + ParseResult::Valid(Motion::NextBigWordEnd) + } + Some('f') => { + let _ = input.next(); + match input.peek() { + Some(&x) => { + input.next(); + ParseResult::Valid(Motion::RightUntil(*x)) + } + None => ParseResult::Incomplete, + } + } + Some('t') => { + let _ = input.next(); + match input.peek() { + Some(&x) => { + input.next(); + ParseResult::Valid(Motion::RightBefore(*x)) + } + None => ParseResult::Incomplete, + } + } + Some('F') => { + let _ = input.next(); + match input.peek() { + Some(&x) => { + input.next(); + ParseResult::Valid(Motion::LeftUntil(*x)) + } + None => ParseResult::Incomplete, + } + } + Some('T') => { + let _ = input.next(); + match input.peek() { + Some(&x) => { + input.next(); + ParseResult::Valid(Motion::LeftBefore(*x)) + } + None => ParseResult::Incomplete, + } + } + ch if ch == command_char.as_ref().as_ref() && command_char.is_some() => { + let _ = input.next(); + ParseResult::Valid(Motion::Line) + } + None => ParseResult::Incomplete, + _ => ParseResult::Invalid, + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Motion { + Left, + Right, + Up, + Down, + NextWord, + NextBigWord, + NextWordEnd, + NextBigWordEnd, + PreviousWord, + PreviousBigWord, + Line, + Start, + End, + RightUntil(char), + RightBefore(char), + LeftUntil(char), + LeftBefore(char), +} + +impl Motion { + pub fn to_reedline(&self, hx_state: &mut Hx) -> Vec { + match self { + Motion::Left => vec![ReedlineOption::Event(ReedlineEvent::UntilFound(vec![ + ReedlineEvent::MenuLeft, + ReedlineEvent::Left, + ]))], + Motion::Right => vec![ReedlineOption::Event(ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + ReedlineEvent::Right, + ]))], + Motion::Up => vec![ReedlineOption::Event(ReedlineEvent::UntilFound(vec![ + ReedlineEvent::MenuUp, + ReedlineEvent::Up, + ]))], + Motion::Down => vec![ReedlineOption::Event(ReedlineEvent::UntilFound(vec![ + ReedlineEvent::MenuDown, + ReedlineEvent::Down, + ]))], + Motion::NextWord => vec![ReedlineOption::Edit(EditCommand::MoveWordRightStart)], + Motion::NextBigWord => vec![ReedlineOption::Edit(EditCommand::MoveBigWordRightStart)], + Motion::NextWordEnd => vec![ReedlineOption::Edit(EditCommand::MoveWordRightEnd)], + Motion::NextBigWordEnd => vec![ReedlineOption::Edit(EditCommand::MoveBigWordRightEnd)], + Motion::PreviousWord => vec![ReedlineOption::Edit(EditCommand::MoveWordLeft)], + Motion::PreviousBigWord => vec![ReedlineOption::Edit(EditCommand::MoveBigWordLeft)], + Motion::Line => vec![], // Placeholder as unusable standalone motion + Motion::Start => vec![ReedlineOption::Edit(EditCommand::MoveToLineStart)], + Motion::End => vec![ReedlineOption::Edit(EditCommand::MoveToLineEnd)], + Motion::RightUntil(ch) => { + hx_state.last_char_search = Some(HxCharSearch::ToRight(*ch)); + vec![ReedlineOption::Edit(EditCommand::MoveRightUntil(*ch))] + } + Motion::RightBefore(ch) => { + hx_state.last_char_search = Some(HxCharSearch::TillRight(*ch)); + vec![ReedlineOption::Edit(EditCommand::MoveRightBefore(*ch))] + } + Motion::LeftUntil(ch) => { + hx_state.last_char_search = Some(HxCharSearch::ToLeft(*ch)); + vec![ReedlineOption::Edit(EditCommand::MoveLeftUntil(*ch))] + } + Motion::LeftBefore(ch) => { + hx_state.last_char_search = Some(HxCharSearch::TillLeft(*ch)); + vec![ReedlineOption::Edit(EditCommand::MoveLeftBefore(*ch))] + } + } + } +} + +/// Vi left-right motions to or till a character. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum HxCharSearch { + /// f + ToRight(char), + /// F + ToLeft(char), + /// t + TillRight(char), + /// T + TillLeft(char), +} + +impl HxCharSearch { + /// Swap the direction of the to or till for ',' + pub fn reverse(&self) -> Self { + match self { + HxCharSearch::ToRight(c) => HxCharSearch::ToLeft(*c), + HxCharSearch::ToLeft(c) => HxCharSearch::ToRight(*c), + HxCharSearch::TillRight(c) => HxCharSearch::TillLeft(*c), + HxCharSearch::TillLeft(c) => HxCharSearch::TillRight(*c), + } + } + + pub fn to_move(&self) -> EditCommand { + match self { + HxCharSearch::ToRight(c) => EditCommand::MoveRightUntil(*c), + HxCharSearch::ToLeft(c) => EditCommand::MoveLeftUntil(*c), + HxCharSearch::TillRight(c) => EditCommand::MoveRightBefore(*c), + HxCharSearch::TillLeft(c) => EditCommand::MoveLeftBefore(*c), + } + } + + pub fn to_cut(&self) -> EditCommand { + match self { + HxCharSearch::ToRight(c) => EditCommand::CutRightUntil(*c), + HxCharSearch::ToLeft(c) => EditCommand::CutLeftUntil(*c), + HxCharSearch::TillRight(c) => EditCommand::CutRightBefore(*c), + HxCharSearch::TillLeft(c) => EditCommand::CutLeftBefore(*c), + } + } +} diff --git a/src/edit_mode/hx/parser.rs b/src/edit_mode/hx/parser.rs new file mode 100644 index 00000000..11374e0e --- /dev/null +++ b/src/edit_mode/hx/parser.rs @@ -0,0 +1,427 @@ +use super::command::{parse_command, Command}; +use super::motion::{parse_motion, Motion}; +use crate::{EditCommand, Hx, ReedlineEvent}; +use std::iter::Peekable; + +#[derive(Debug, Clone)] +pub enum ReedlineOption { + Event(ReedlineEvent), + Edit(EditCommand), + Incomplete, +} + +impl ReedlineOption { + pub fn into_reedline_event(self) -> Option { + match self { + ReedlineOption::Event(event) => Some(event), + ReedlineOption::Edit(edit) => Some(ReedlineEvent::Edit(vec![edit])), + ReedlineOption::Incomplete => None, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ParseResult { + Valid(T), + Incomplete, + Invalid, +} + +impl ParseResult { + fn is_invalid(&self) -> bool { + match self { + ParseResult::Valid(_) => false, + ParseResult::Incomplete => false, + ParseResult::Invalid => true, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ParsedHxSequence { + multiplier: Option, + command: Option, + count: Option, + motion: Option>, +} + +impl ParsedHxSequence { + pub fn is_valid(&self) -> bool { + match self.motion { + Some(parse_res) => !parse_res.is_invalid(), + None => false, + } + } + + pub fn is_complete(&self) -> bool { + match (&self.command, &self.motion) { + (None, ParseResult::Valid(_)) => true, + (Some(Command::Incomplete), _) => false, + (Some(cmd), ParseResult::Incomplete) if !cmd.requires_motion() => true, + (Some(_), ParseResult::Valid(_)) => true, + (Some(cmd), ParseResult::Incomplete) if cmd.requires_motion() => false, + _ => false, + } + } + + /// Combine `multiplier` and `count` as vim only considers the product + /// + /// Default return value: 1 + /// + /// ### Note: + /// + /// https://github.com/vim/vim/blob/140f6d0eda7921f2f0b057ec38ed501240903fc3/runtime/doc/motion.txt#L64-L70 + fn total_multiplier(&self) -> usize { + self.multiplier.unwrap_or(1) * self.count.unwrap_or(1) + } + + fn apply_multiplier(&self, raw_events: Option>) -> ReedlineEvent { + if let Some(raw_events) = raw_events { + let events = std::iter::repeat(raw_events) + .take(self.total_multiplier()) + .flatten() + .filter_map(ReedlineOption::into_reedline_event) + .collect::>(); + + if events.is_empty() || events.contains(&ReedlineEvent::None) { + // TODO: Clarify if the `contains(ReedlineEvent::None)` path is relevant + ReedlineEvent::None + } else { + ReedlineEvent::Multiple(events) + } + } else { + ReedlineEvent::None + } + } + + pub fn enters_insert_mode(&self) -> bool { + matches!( + (&self.command, &self.motion), + (Some(Command::EnterHxInsert), Some(ParseResult::Incomplete)) + | (Some(Command::EnterHxAppend), Some(ParseResult::Incomplete)) + | (Some(Command::AppendToEnd), Some(ParseResult::Incomplete)) + | (Some(Command::PrependToStart), Some(ParseResult::Incomplete)) + | (Some(Command::HistorySearch), Some(ParseResult::Incomplete)) + | (Some(Command::Change), Some(ParseResult::Valid(_))) + ) + } + + pub fn to_reedline_event(&self, hx_state: &mut Hx) -> ReedlineEvent { + match (&self.multiplier, &self.command, &self.count, &self.motion) { + (_, Some(command), None, Some(ParseResult::Incomplete)) => { + let events = self.apply_multiplier(Some(command.to_reedline(hx_state))); + match &events { + ReedlineEvent::None => {} + event => hx_state.previous = Some(event.clone()), + } + events + } + // This case handles all combinations of commands and motions that could exist + (_, Some(command), _, ParseResult::Valid(motion)) => { + let events = + self.apply_multiplier(command.to_reedline_with_motion(motion, hx_state)); + match &events { + ReedlineEvent::None => {} + event => hx_state.previous = Some(event.clone()), + } + events + } + (_, None, _, ParseResult::Valid(motion)) => { + self.apply_multiplier(Some(motion.to_reedline(hx_state))) + } + _ => ReedlineEvent::None, + } + } +} + +fn parse_number<'iter, I>(input: &mut Peekable) -> Option +where + I: Iterator, +{ + match input.peek() { + Some('0') => None, + Some(x) if x.is_ascii_digit() => { + let mut count: usize = 0; + while let Some(&c) = input.peek() { + if c.is_ascii_digit() { + let c = c.to_digit(10).expect("already checked if is a digit"); + let _ = input.next(); + count *= 10; + count += c as usize; + } else { + return Some(count); + } + } + Some(count) + } + _ => None, + } +} + +pub fn parse<'iter, I>(input: &mut Peekable) -> ParsedHxSequence +where + I: Iterator, +{ + let multiplier = parse_number(input); + let command = parse_command(input); + let count = parse_number(input); + let motion = parse_motion(input, command.as_ref().and_then(Command::whole_line_char)); + + ParsedHxSequence { + multiplier, + command, + count, + motion, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + fn hx_parse(input: &[char]) -> ParsedHxSequence { + parse(&mut input.iter().peekable()) + } + + #[test] + fn test_delete_word() { + let input = ['w']; + let output = hx_parse(&input); + + assert_eq!( + output, + ParsedHxSequence { + multiplier: None, + command: None, + count: None, + motion: ParseResult::Valid(Motion::NextWord), + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[test] + fn test_two_delete_word() { + let input = ['d']; + let output = hx_parse(&input); + + assert_eq!( + output, + ParsedHxSequence { + multiplier: None, + command: Some(Command::Delete), + count: None, + motion: None, + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[test] + fn test_delete_twenty_word() { + let input = ['2', '0', 'w']; + let output = hx_parse(&input); + + assert_eq!( + output, + ParsedHxSequence { + multiplier: None, + command: None, + count: Some(20), + motion: ParseResult::Valid(Motion::NextWord), + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[test] + fn test_find_action() { + let input = ['t', 'd']; + let output = hx_parse(&input); + + assert_eq!( + output, + ParsedHxSequence { + multiplier: None, + command: None, + count: None, + motion: ParseResult::Valid(Motion::RightBefore('d')), + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[test] + fn test_select_around_paren() { + let input = ['m', 'a', '(']; + let output = hx_parse(&input); + + assert_eq!( + output, + ParsedHxSequence { + multiplier: None, + command: None, + count: None, + motion: ParseResult::SelectOuterPair, + } + ); + assert_eq!(output.is_valid(), false); + } + + #[test] + fn test_partial_action() { + let input = ['r']; + let output = hx_parse(&input); + + assert_eq!( + output, + ParsedHxSequence { + multiplier: None, + command: Some(Command::Incomplete), + count: None, + motion: ParseResult::Incomplete, + } + ); + + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), false); + } + + #[test] + fn test_partial_motion() { + let input = ['f']; + let output = hx_parse(&input); + + assert_eq!( + output, + ParsedHxSequence { + multiplier: None, + command: None, + count: None, + motion: ParseResult::Incomplete, + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), false); + } + + #[test] + fn test_two_char_action_replace() { + let input = ['r', 'k']; + let output = hx_parse(&input); + + assert_eq!( + output, + ParsedHxSequence { + multiplier: None, + command: Some(Command::ReplaceChar('k')), + count: None, + motion: ParseResult::Incomplete, + } + ); + + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[test] + fn test_find_motion() { + let input = ['2', 'f', 'f']; + let output = hx_parse(&input); + + assert_eq!( + output, + ParsedHxSequence { + multiplier: Some(2), + command: None, + count: None, + motion: ParseResult::Valid(Motion::RightUntil('f')), + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[test] + fn test_two_up() { + let input = ['2', 'k']; + let output = hx_parse(&input); + + assert_eq!( + output, + ParsedHxSequence { + multiplier: Some(2), + command: None, + count: None, + motion: ParseResult::Valid(Motion::Up), + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[rstest] + #[case(&['2', 'k'], ReedlineEvent::Multiple(vec![ReedlineEvent::UntilFound(vec![ + ReedlineEvent::MenuUp, + ReedlineEvent::Up, + ]), ReedlineEvent::UntilFound(vec![ + ReedlineEvent::MenuUp, + ReedlineEvent::Up, + ])]))] + #[case(&['k'], ReedlineEvent::Multiple(vec![ReedlineEvent::UntilFound(vec![ + ReedlineEvent::MenuUp, + ReedlineEvent::Up, + ])]))] + #[case(&['w'], + ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveWordRightStart])]))] + #[case(&['W'], + ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveBigWordRightStart])]))] + #[case(&['2', 'l'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + ReedlineEvent::Right, + ]),ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + ReedlineEvent::Right, + ]) ]))] + #[case(&['l'], ReedlineEvent::Multiple(vec![ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + ReedlineEvent::Right, + ])]))] + #[case(&['0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart])]))] + #[case(&['$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineEnd])]))] + #[case(&['i'], ReedlineEvent::Multiple(vec![ReedlineEvent::Repaint]))] + #[case(&['p'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter])]))] + #[case(&['2', 'p'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), + ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]) + ]))] + #[case(&['u'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Undo])]))] + #[case(&['2', 'u'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::Undo]), + ReedlineEvent::Edit(vec![EditCommand::Undo]) + ]))] + #[case(&['d', 'd'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine])]))] + #[case(&['d', 'w'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRightToNext])]))] + #[case(&['d', 'W'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRightToNext])]))] + #[case(&['d', 'e'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRight])]))] + #[case(&['d', 'b'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordLeft])]))] + #[case(&['d', 'B'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordLeft])]))] + fn test_reedline_move(#[case] input: &[char], #[case] expected: ReedlineEvent) { + let mut hx = Hx::default(); + let res = hx_parse(input); + let output = res.to_reedline_event(&mut hx); + + assert_eq!(output, expected); + } +} diff --git a/src/edit_mode/mod.rs b/src/edit_mode/mod.rs index 38e1456f..8e571524 100644 --- a/src/edit_mode/mod.rs +++ b/src/edit_mode/mod.rs @@ -3,9 +3,11 @@ mod cursors; mod emacs; mod keybindings; mod vi; +mod hx; pub use base::EditMode; pub use cursors::CursorConfig; pub use emacs::{default_emacs_keybindings, Emacs}; pub use keybindings::Keybindings; pub use vi::{default_vi_insert_keybindings, default_vi_normal_keybindings, Vi}; +pub use hx::{default_hx_insert_keybindings, default_hx_normal_keybindings, Hx}; diff --git a/src/lib.rs b/src/lib.rs index 25527356..95f4e34d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -253,13 +253,14 @@ pub use history::{ mod prompt; pub use prompt::{ DefaultPrompt, DefaultPromptSegment, Prompt, PromptEditMode, PromptHistorySearch, - PromptHistorySearchStatus, PromptViMode, + PromptHistorySearchStatus, PromptHxMode, PromptViMode, }; mod edit_mode; pub use edit_mode::{ - default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings, - CursorConfig, EditMode, Emacs, Keybindings, Vi, + default_emacs_keybindings, default_hx_insert_keybindings, default_hx_normal_keybindings, + default_vi_insert_keybindings, default_vi_normal_keybindings, CursorConfig, EditMode, Emacs, + Hx, Keybindings, Vi, }; mod highlighter; diff --git a/src/prompt/base.rs b/src/prompt/base.rs index 01d91658..101b3891 100644 --- a/src/prompt/base.rs +++ b/src/prompt/base.rs @@ -54,10 +54,24 @@ pub enum PromptEditMode { /// A vi-specific mode Vi(PromptViMode), + /// A hx-specific mode + Hx(PromptHxMode), + /// A custom mode Custom(String), } +/// The hx-specific modes that the prompt can be in +#[derive(Serialize, Deserialize, Clone, Debug, EnumIter, Default)] +pub enum PromptHxMode { + /// The default mode + #[default] + Normal, + + /// Insertion mode + Insert, +} + /// The vi-specific modes that the prompt can be in #[derive(Serialize, Deserialize, Clone, Debug, EnumIter, Default)] pub enum PromptViMode { @@ -75,6 +89,7 @@ impl Display for PromptEditMode { PromptEditMode::Default => write!(f, "Default"), PromptEditMode::Emacs => write!(f, "Emacs"), PromptEditMode::Vi(_) => write!(f, "Vi_Normal\nVi_Insert"), + PromptEditMode::Hx(_) => write!(f, "Hx_Normal\nHx_Insert"), PromptEditMode::Custom(s) => write!(f, "Custom_{s}"), } } diff --git a/src/prompt/default.rs b/src/prompt/default.rs index f56ae903..d33d55b4 100644 --- a/src/prompt/default.rs +++ b/src/prompt/default.rs @@ -1,4 +1,7 @@ -use crate::{Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptViMode}; +use crate::{ + Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptHxMode, + PromptViMode, +}; use { chrono::Local, @@ -9,6 +12,8 @@ use { pub static DEFAULT_PROMPT_INDICATOR: &str = "〉"; pub static DEFAULT_VI_INSERT_PROMPT_INDICATOR: &str = ": "; pub static DEFAULT_VI_NORMAL_PROMPT_INDICATOR: &str = "〉"; +pub static DEFAULT_HX_INSERT_PROMPT_INDICATOR: &str = ": "; +pub static DEFAULT_HX_NORMAL_PROMPT_INDICATOR: &str = "〉"; pub static DEFAULT_MULTILINE_INDICATOR: &str = "::: "; /// Simple [`Prompt`] displaying a configurable left and a right prompt. @@ -66,6 +71,10 @@ impl Prompt for DefaultPrompt { PromptViMode::Normal => DEFAULT_VI_NORMAL_PROMPT_INDICATOR.into(), PromptViMode::Insert => DEFAULT_VI_INSERT_PROMPT_INDICATOR.into(), }, + PromptEditMode::Hx(hx_mode) => match hx_mode { + PromptHxMode::Normal => DEFAULT_HX_NORMAL_PROMPT_INDICATOR.into(), + PromptHxMode::Insert => DEFAULT_HX_INSERT_PROMPT_INDICATOR.into(), + }, PromptEditMode::Custom(str) => format!("({str})").into(), } } diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 83d2e3b5..2b6e7f98 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -2,7 +2,8 @@ mod base; mod default; pub use base::{ - Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptViMode, + Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptHxMode, + PromptViMode, }; pub use default::{DefaultPrompt, DefaultPromptSegment};