use std::num::NonZeroUsize;
use super::{
key::KeyToken,
motion::{
CharSearch, CharSearchDirection, CharSearchPlacement, ColumnMotion, LineAddress, Motion,
PageDirection, ParagraphDirection, WordEndMotion, WordKind,
},
search::{SearchDirection, SearchRepeatDirection},
};
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Count(NonZeroUsize);
impl Count {
#[must_use]
pub const fn new(value: NonZeroUsize) -> Self {
Self(value)
}
#[must_use]
pub const fn get(self) -> usize {
self.0.get()
}
}
impl Default for Count {
fn default() -> Self {
Self(NonZeroUsize::MIN)
}
}
impl From<NonZeroUsize> for Count {
fn from(value: NonZeroUsize) -> Self {
Self::new(value)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Counted<T> {
pub count: Count,
pub item: T,
}
impl<T> Counted<T> {
#[must_use]
pub fn once(item: T) -> Self {
Self {
count: Count::default(),
item,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum NormalCommand {
Motion(Counted<Motion>),
Operator {
count: Count,
operator: Operator,
motion: Counted<Motion>,
},
ModeSwitch(ModeSwitch),
ExCommandStart,
SearchStart(SearchDirection),
SearchRepeat(SearchRepeatDirection),
ViewportPosition(ViewportPosition),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Operator {
Delete,
Yank,
Change,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ModeSwitch {
VisualCharacterwise,
VisualLinewise,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ViewportPosition {
Top,
Center,
Bottom,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum NormalGrammarOutput {
Pending,
Command(NormalCommand),
Unmatched,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PendingPrefix {
G,
Z,
CharSearch {
direction: CharSearchDirection,
placement: CharSearchPlacement,
},
}
#[derive(Clone, Debug, Default)]
pub struct NormalGrammar {
count: Option<NonZeroUsize>,
pending: Option<PendingPrefix>,
}
impl NormalGrammar {
pub const fn reset(&mut self) {
self.count = None;
self.pending = None;
}
pub fn feed(&mut self, token: KeyToken) -> NormalGrammarOutput {
if let Some(pending) = self.pending {
return self.finish_pending(pending, token);
}
if let Some(digit) = token.count_digit()
&& (digit != 0 || self.count.is_some())
{
self.push_count_digit(digit);
return NormalGrammarOutput::Pending;
}
match token {
KeyToken::Char('h') => self.motion(Motion::Left),
KeyToken::Char('j') => self.motion(Motion::Down),
KeyToken::Char('k') => self.motion(Motion::Up),
KeyToken::Char('l') => self.motion(Motion::Right),
KeyToken::Ctrl('f' | 'F') => self.motion(Motion::Page(PageDirection::Forward)),
KeyToken::Ctrl('b' | 'B') => self.motion(Motion::Page(PageDirection::Backward)),
KeyToken::Char('w') => self.motion(Motion::WordForward(WordKind::Normal)),
KeyToken::Char('W') => self.motion(Motion::WordForward(WordKind::Big)),
KeyToken::Char('b') => self.motion(Motion::WordBackward(WordKind::Normal)),
KeyToken::Char('B') => self.motion(Motion::WordBackward(WordKind::Big)),
KeyToken::Char('e') => {
self.motion(Motion::WordEnd(WordEndMotion::Forward(WordKind::Normal)))
}
KeyToken::Char('E') => {
self.motion(Motion::WordEnd(WordEndMotion::Forward(WordKind::Big)))
}
KeyToken::Char('0') => self.motion(Motion::Column(ColumnMotion::LineStart)),
KeyToken::Char('^') => self.motion(Motion::Column(ColumnMotion::FirstNonBlank)),
KeyToken::Char('$') => self.motion(Motion::Column(ColumnMotion::LineEnd)),
KeyToken::Char('|') => self.motion(Motion::Column(ColumnMotion::ScreenColumn)),
KeyToken::Char('G') => {
let address = self
.take_count()
.map_or(LineAddress::LastNonBlank, |count| {
LineAddress::Number(count.0)
});
NormalGrammarOutput::Command(NormalCommand::Motion(Counted {
count: Count::default(),
item: Motion::LineAddress(address),
}))
}
KeyToken::Char('g') => {
self.pending = Some(PendingPrefix::G);
NormalGrammarOutput::Pending
}
KeyToken::Char('z') => {
self.pending = Some(PendingPrefix::Z);
NormalGrammarOutput::Pending
}
KeyToken::Char('f') => {
self.pending_char_search(CharSearchDirection::Forward, CharSearchPlacement::OnMatch)
}
KeyToken::Char('F') => self
.pending_char_search(CharSearchDirection::Backward, CharSearchPlacement::OnMatch),
KeyToken::Char('t') => self.pending_char_search(
CharSearchDirection::Forward,
CharSearchPlacement::BeforeMatch,
),
KeyToken::Char('T') => self.pending_char_search(
CharSearchDirection::Backward,
CharSearchPlacement::BeforeMatch,
),
KeyToken::Char(';') => self.motion(Motion::RepeatCharSearch),
KeyToken::Char(',') => self.motion(Motion::RepeatCharSearchReversed),
KeyToken::Char('}') => self.motion(Motion::Paragraph(ParagraphDirection::Forward)),
KeyToken::Char('{') => self.motion(Motion::Paragraph(ParagraphDirection::Backward)),
KeyToken::Char(':') => self.command(NormalCommand::ExCommandStart),
KeyToken::Char('/') => {
self.command(NormalCommand::SearchStart(SearchDirection::Forward))
}
KeyToken::Char('?') => {
self.command(NormalCommand::SearchStart(SearchDirection::Backward))
}
KeyToken::Char('n') => {
self.command(NormalCommand::SearchRepeat(SearchRepeatDirection::Next))
}
KeyToken::Char('N') => {
self.command(NormalCommand::SearchRepeat(SearchRepeatDirection::Previous))
}
KeyToken::Char('v') => {
self.command(NormalCommand::ModeSwitch(ModeSwitch::VisualCharacterwise))
}
KeyToken::Char('V') => {
self.command(NormalCommand::ModeSwitch(ModeSwitch::VisualLinewise))
}
_ => {
self.reset();
NormalGrammarOutput::Unmatched
}
}
}
fn finish_pending(&mut self, pending: PendingPrefix, token: KeyToken) -> NormalGrammarOutput {
self.pending = None;
match pending {
PendingPrefix::G if token == KeyToken::Char('g') => {
let address = self
.take_count()
.map_or(LineAddress::FirstNonBlank, |count| {
LineAddress::Number(count.0)
});
NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(
Motion::LineAddress(address),
)))
}
PendingPrefix::G if token == KeyToken::Char('e') => {
self.command(NormalCommand::Motion(Counted::once(Motion::WordEnd(
WordEndMotion::Backward(WordKind::Normal),
))))
}
PendingPrefix::G if token == KeyToken::Char('E') => {
self.command(NormalCommand::Motion(Counted::once(Motion::WordEnd(
WordEndMotion::Backward(WordKind::Big),
))))
}
PendingPrefix::CharSearch {
direction,
placement,
} => {
if let KeyToken::Char(target) = token {
let count = self.take_count().unwrap_or_default();
self.command(NormalCommand::Motion(Counted {
count,
item: Motion::CharSearch(CharSearch {
target,
direction,
placement,
}),
}))
} else {
self.reset();
NormalGrammarOutput::Unmatched
}
}
PendingPrefix::G => {
self.reset();
NormalGrammarOutput::Unmatched
}
PendingPrefix::Z => match token {
KeyToken::Char('t') => {
self.command(NormalCommand::ViewportPosition(ViewportPosition::Top))
}
KeyToken::Char('z') => {
self.command(NormalCommand::ViewportPosition(ViewportPosition::Center))
}
KeyToken::Char('b') => {
self.command(NormalCommand::ViewportPosition(ViewportPosition::Bottom))
}
_ => {
self.reset();
NormalGrammarOutput::Unmatched
}
},
}
}
fn motion(&mut self, motion: Motion) -> NormalGrammarOutput {
let count = self.take_count().unwrap_or_default();
self.command(NormalCommand::Motion(Counted {
count,
item: motion,
}))
}
const fn command(&mut self, command: NormalCommand) -> NormalGrammarOutput {
self.reset();
NormalGrammarOutput::Command(command)
}
const fn pending_char_search(
&mut self,
direction: CharSearchDirection,
placement: CharSearchPlacement,
) -> NormalGrammarOutput {
self.pending = Some(PendingPrefix::CharSearch {
direction,
placement,
});
NormalGrammarOutput::Pending
}
fn push_count_digit(&mut self, digit: usize) {
let next = self.count.map_or(digit, |count| {
count.get().saturating_mul(10).saturating_add(digit)
});
self.count = NonZeroUsize::new(next);
}
fn take_count(&mut self) -> Option<Count> {
self.count.take().map(Count::from)
}
}
#[cfg(test)]
mod tests {
use super::{Counted, NormalCommand, NormalGrammar, NormalGrammarOutput};
use crate::vim::{
CharSearch, CharSearchDirection, CharSearchPlacement, ColumnMotion, KeyToken, LineAddress,
Motion, PageDirection, ViewportPosition, WordKind, motion::WordEndMotion,
search::SearchRepeatDirection,
};
use proptest::prelude::*;
fn feed_chars(grammar: &mut NormalGrammar, keys: &str) -> NormalGrammarOutput {
let mut output = NormalGrammarOutput::Pending;
for character in keys.chars() {
output = grammar.feed(KeyToken::Char(character));
}
output
}
#[test]
fn parses_counts_outside_relative_motions() {
let mut grammar = NormalGrammar::default();
assert_eq!(
feed_chars(&mut grammar, "10j"),
NormalGrammarOutput::Command(NormalCommand::Motion(Counted {
count: std::num::NonZeroUsize::new(10).unwrap().into(),
item: Motion::Down,
}))
);
}
#[test]
fn parses_line_addresses_with_count_aware_targets() {
let mut grammar = NormalGrammar::default();
assert_eq!(
feed_chars(&mut grammar, "100gg"),
NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(
Motion::LineAddress(LineAddress::Number(
std::num::NonZeroUsize::new(100).unwrap()
))
)))
);
assert_eq!(
feed_chars(&mut grammar, "G"),
NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(
Motion::LineAddress(LineAddress::LastNonBlank)
)))
);
}
#[test]
fn parses_character_search_target_as_motion_payload() {
let mut grammar = NormalGrammar::default();
assert_eq!(
grammar.feed(KeyToken::Char('f')),
NormalGrammarOutput::Pending
);
assert_eq!(
grammar.feed(KeyToken::Char('b')),
NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(Motion::CharSearch(
CharSearch {
target: 'b',
direction: CharSearchDirection::Forward,
placement: CharSearchPlacement::OnMatch,
}
))))
);
}
#[test]
fn parses_control_page_motions() {
let mut grammar = NormalGrammar::default();
assert_eq!(
grammar.feed(KeyToken::Ctrl('f')),
NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(Motion::Page(
PageDirection::Forward
))))
);
assert_eq!(
grammar.feed(KeyToken::Ctrl('b')),
NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(Motion::Page(
PageDirection::Backward
))))
);
}
#[test]
fn parses_viewport_position_commands() {
let mut grammar = NormalGrammar::default();
assert_eq!(
feed_chars(&mut grammar, "zt"),
NormalGrammarOutput::Command(NormalCommand::ViewportPosition(ViewportPosition::Top))
);
assert_eq!(
feed_chars(&mut grammar, "zz"),
NormalGrammarOutput::Command(NormalCommand::ViewportPosition(ViewportPosition::Center))
);
assert_eq!(
feed_chars(&mut grammar, "zb"),
NormalGrammarOutput::Command(NormalCommand::ViewportPosition(ViewportPosition::Bottom))
);
}
#[test]
fn parses_search_repeat_commands() {
let mut grammar = NormalGrammar::default();
assert_eq!(
grammar.feed(KeyToken::Char('n')),
NormalGrammarOutput::Command(NormalCommand::SearchRepeat(SearchRepeatDirection::Next))
);
assert_eq!(
grammar.feed(KeyToken::Char('N')),
NormalGrammarOutput::Command(NormalCommand::SearchRepeat(
SearchRepeatDirection::Previous
))
);
}
#[test]
fn unsupported_viewport_position_resets_pending_prefix() {
let mut grammar = NormalGrammar::default();
assert_eq!(
grammar.feed(KeyToken::Char('z')),
NormalGrammarOutput::Pending
);
assert_eq!(
grammar.feed(KeyToken::Char('x')),
NormalGrammarOutput::Unmatched
);
assert_eq!(
grammar.feed(KeyToken::Char('j')),
NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(Motion::Down)))
);
}
proptest! {
#[test]
fn parses_relative_motion_counts_generically(count in 1usize..10_000, key in prop_oneof![
Just('h'),
Just('j'),
Just('k'),
Just('l'),
Just('w'),
Just('W'),
Just('b'),
Just('B'),
Just('e'),
Just('E'),
Just('$'),
Just('^'),
]) {
let mut grammar = NormalGrammar::default();
let input = format!("{count}{key}");
let output = feed_chars(&mut grammar, &input);
let motion = match key {
'h' => Motion::Left,
'j' => Motion::Down,
'k' => Motion::Up,
'l' => Motion::Right,
'w' => Motion::WordForward(WordKind::Normal),
'W' => Motion::WordForward(WordKind::Big),
'b' => Motion::WordBackward(WordKind::Normal),
'B' => Motion::WordBackward(WordKind::Big),
'e' => Motion::WordEnd(WordEndMotion::Forward(WordKind::Normal)),
'E' => Motion::WordEnd(WordEndMotion::Forward(WordKind::Big)),
'$' => Motion::Column(ColumnMotion::LineEnd),
'^' => Motion::Column(ColumnMotion::FirstNonBlank),
_ => unreachable!("strategy only emits supported keys"),
};
prop_assert_eq!(
output,
NormalGrammarOutput::Command(NormalCommand::Motion(Counted {
count: std::num::NonZeroUsize::new(count).unwrap().into(),
item: motion,
}))
);
}
#[test]
fn char_search_target_is_not_reinterpreted_as_a_followup_motion(target in any::<char>()) {
let mut grammar = NormalGrammar::default();
prop_assert_eq!(grammar.feed(KeyToken::Char('f')), NormalGrammarOutput::Pending);
prop_assert_eq!(
grammar.feed(KeyToken::Char(target)),
NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(
Motion::CharSearch(CharSearch {
target,
direction: CharSearchDirection::Forward,
placement: CharSearchPlacement::OnMatch,
})
)))
);
}
}
}