Skip to main content

alma/vim/grammar/
mod.rs

1//! Typed normal-mode Vim grammar.
2
3use std::num::NonZeroUsize;
4
5use super::{
6    key::KeyToken,
7    motion::{
8        CharSearch, CharSearchDirection, CharSearchPlacement, ColumnMotion, LineAddress, Motion,
9        PageDirection, ParagraphDirection, WordEndMotion, WordKind,
10    },
11    search::{SearchDirection, SearchRepeatDirection},
12};
13
14/// Non-zero Vim count.
15#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
16pub struct Count(NonZeroUsize);
17
18impl Count {
19    /// Creates a count from a non-zero value.
20    #[must_use]
21    pub const fn new(value: NonZeroUsize) -> Self {
22        Self(value)
23    }
24
25    /// Returns the underlying count.
26    #[must_use]
27    pub const fn get(self) -> usize {
28        self.0.get()
29    }
30}
31
32impl Default for Count {
33    fn default() -> Self {
34        Self(NonZeroUsize::MIN)
35    }
36}
37
38impl From<NonZeroUsize> for Count {
39    fn from(value: NonZeroUsize) -> Self {
40        Self::new(value)
41    }
42}
43
44/// A parsed item with Vim's generic count prefix.
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub struct Counted<T> {
47    /// Prefix count, defaulting to one.
48    pub count: Count,
49    /// Parsed item.
50    pub item: T,
51}
52
53impl<T> Counted<T> {
54    /// Creates an unprefixed counted item.
55    #[must_use]
56    pub fn once(item: T) -> Self {
57        Self {
58            count: Count::default(),
59            item,
60        }
61    }
62}
63
64/// A typed normal-mode command emitted by the grammar.
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub enum NormalCommand {
67    /// Move the cursor.
68    Motion(Counted<Motion>),
69    /// Apply an operator to a motion.
70    Operator {
71        /// Operator count.
72        count: Count,
73        /// Operator kind.
74        operator: Operator,
75        /// Motion target.
76        motion: Counted<Motion>,
77    },
78    /// Switch editor mode.
79    ModeSwitch(ModeSwitch),
80    /// Start the `:` command-line.
81    ExCommandStart,
82    /// Start a `/` or `?` search.
83    SearchStart(SearchDirection),
84    /// Repeat the last search using Vim's `n`/`N` commands.
85    SearchRepeat(SearchRepeatDirection),
86    /// Reposition the viewport around the cursor.
87    ViewportPosition(ViewportPosition),
88}
89
90/// Supported operator prefixes.
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub enum Operator {
93    /// Delete.
94    Delete,
95    /// Yank.
96    Yank,
97    /// Change.
98    Change,
99}
100
101/// Supported mode switches from normal mode.
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103pub enum ModeSwitch {
104    /// `v`
105    VisualCharacterwise,
106    /// `V`
107    VisualLinewise,
108}
109
110/// Supported cursor-relative viewport positions.
111#[derive(Clone, Copy, Debug, Eq, PartialEq)]
112pub enum ViewportPosition {
113    /// `zt`
114    Top,
115    /// `zz`
116    Center,
117    /// `zb`
118    Bottom,
119}
120
121/// Output from feeding one token to the normal grammar.
122#[derive(Clone, Copy, Debug, Eq, PartialEq)]
123pub enum NormalGrammarOutput {
124    /// No complete command yet.
125    Pending,
126    /// A complete typed command.
127    Command(NormalCommand),
128    /// The token does not form a supported command.
129    Unmatched,
130}
131
132/// A partially parsed prefix waiting for a target key.
133#[derive(Clone, Copy, Debug, Eq, PartialEq)]
134enum PendingPrefix {
135    /// `g`, waiting for `g`.
136    G,
137    /// `z`, waiting for a viewport-position target.
138    Z,
139    /// `f`/`F`/`t`/`T`, waiting for the target character.
140    CharSearch {
141        /// Search direction.
142        direction: CharSearchDirection,
143        /// Landing placement.
144        placement: CharSearchPlacement,
145    },
146}
147
148/// Stateful normal-mode grammar parser.
149#[derive(Clone, Debug, Default)]
150pub struct NormalGrammar {
151    /// Pending count prefix.
152    count: Option<NonZeroUsize>,
153    /// Pending command prefix.
154    pending: Option<PendingPrefix>,
155}
156
157impl NormalGrammar {
158    /// Clears all pending prefixes.
159    pub const fn reset(&mut self) {
160        self.count = None;
161        self.pending = None;
162    }
163
164    /// Feeds one normalized key token into the parser.
165    pub fn feed(&mut self, token: KeyToken) -> NormalGrammarOutput {
166        if let Some(pending) = self.pending {
167            return self.finish_pending(pending, token);
168        }
169
170        if let Some(digit) = token.count_digit()
171            && (digit != 0 || self.count.is_some())
172        {
173            self.push_count_digit(digit);
174            return NormalGrammarOutput::Pending;
175        }
176
177        match token {
178            KeyToken::Char('h') => self.motion(Motion::Left),
179            KeyToken::Char('j') => self.motion(Motion::Down),
180            KeyToken::Char('k') => self.motion(Motion::Up),
181            KeyToken::Char('l') => self.motion(Motion::Right),
182            KeyToken::Ctrl('f' | 'F') => self.motion(Motion::Page(PageDirection::Forward)),
183            KeyToken::Ctrl('b' | 'B') => self.motion(Motion::Page(PageDirection::Backward)),
184            KeyToken::Char('w') => self.motion(Motion::WordForward(WordKind::Normal)),
185            KeyToken::Char('W') => self.motion(Motion::WordForward(WordKind::Big)),
186            KeyToken::Char('b') => self.motion(Motion::WordBackward(WordKind::Normal)),
187            KeyToken::Char('B') => self.motion(Motion::WordBackward(WordKind::Big)),
188            KeyToken::Char('e') => {
189                self.motion(Motion::WordEnd(WordEndMotion::Forward(WordKind::Normal)))
190            }
191            KeyToken::Char('E') => {
192                self.motion(Motion::WordEnd(WordEndMotion::Forward(WordKind::Big)))
193            }
194            KeyToken::Char('0') => self.motion(Motion::Column(ColumnMotion::LineStart)),
195            KeyToken::Char('^') => self.motion(Motion::Column(ColumnMotion::FirstNonBlank)),
196            KeyToken::Char('$') => self.motion(Motion::Column(ColumnMotion::LineEnd)),
197            KeyToken::Char('|') => self.motion(Motion::Column(ColumnMotion::ScreenColumn)),
198            KeyToken::Char('G') => {
199                let address = self
200                    .take_count()
201                    .map_or(LineAddress::LastNonBlank, |count| {
202                        LineAddress::Number(count.0)
203                    });
204                NormalGrammarOutput::Command(NormalCommand::Motion(Counted {
205                    count: Count::default(),
206                    item: Motion::LineAddress(address),
207                }))
208            }
209            KeyToken::Char('g') => {
210                self.pending = Some(PendingPrefix::G);
211                NormalGrammarOutput::Pending
212            }
213            KeyToken::Char('z') => {
214                self.pending = Some(PendingPrefix::Z);
215                NormalGrammarOutput::Pending
216            }
217            KeyToken::Char('f') => {
218                self.pending_char_search(CharSearchDirection::Forward, CharSearchPlacement::OnMatch)
219            }
220            KeyToken::Char('F') => self
221                .pending_char_search(CharSearchDirection::Backward, CharSearchPlacement::OnMatch),
222            KeyToken::Char('t') => self.pending_char_search(
223                CharSearchDirection::Forward,
224                CharSearchPlacement::BeforeMatch,
225            ),
226            KeyToken::Char('T') => self.pending_char_search(
227                CharSearchDirection::Backward,
228                CharSearchPlacement::BeforeMatch,
229            ),
230            KeyToken::Char(';') => self.motion(Motion::RepeatCharSearch),
231            KeyToken::Char(',') => self.motion(Motion::RepeatCharSearchReversed),
232            KeyToken::Char('}') => self.motion(Motion::Paragraph(ParagraphDirection::Forward)),
233            KeyToken::Char('{') => self.motion(Motion::Paragraph(ParagraphDirection::Backward)),
234            KeyToken::Char(':') => self.command(NormalCommand::ExCommandStart),
235            KeyToken::Char('/') => {
236                self.command(NormalCommand::SearchStart(SearchDirection::Forward))
237            }
238            KeyToken::Char('?') => {
239                self.command(NormalCommand::SearchStart(SearchDirection::Backward))
240            }
241            KeyToken::Char('n') => {
242                self.command(NormalCommand::SearchRepeat(SearchRepeatDirection::Next))
243            }
244            KeyToken::Char('N') => {
245                self.command(NormalCommand::SearchRepeat(SearchRepeatDirection::Previous))
246            }
247            KeyToken::Char('v') => {
248                self.command(NormalCommand::ModeSwitch(ModeSwitch::VisualCharacterwise))
249            }
250            KeyToken::Char('V') => {
251                self.command(NormalCommand::ModeSwitch(ModeSwitch::VisualLinewise))
252            }
253            _ => {
254                self.reset();
255                NormalGrammarOutput::Unmatched
256            }
257        }
258    }
259
260    /// Attempts to complete a pending prefix with `token`.
261    fn finish_pending(&mut self, pending: PendingPrefix, token: KeyToken) -> NormalGrammarOutput {
262        self.pending = None;
263
264        match pending {
265            PendingPrefix::G if token == KeyToken::Char('g') => {
266                let address = self
267                    .take_count()
268                    .map_or(LineAddress::FirstNonBlank, |count| {
269                        LineAddress::Number(count.0)
270                    });
271                NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(
272                    Motion::LineAddress(address),
273                )))
274            }
275            PendingPrefix::G if token == KeyToken::Char('e') => {
276                self.command(NormalCommand::Motion(Counted::once(Motion::WordEnd(
277                    WordEndMotion::Backward(WordKind::Normal),
278                ))))
279            }
280            PendingPrefix::G if token == KeyToken::Char('E') => {
281                self.command(NormalCommand::Motion(Counted::once(Motion::WordEnd(
282                    WordEndMotion::Backward(WordKind::Big),
283                ))))
284            }
285            PendingPrefix::CharSearch {
286                direction,
287                placement,
288            } => {
289                if let KeyToken::Char(target) = token {
290                    let count = self.take_count().unwrap_or_default();
291                    self.command(NormalCommand::Motion(Counted {
292                        count,
293                        item: Motion::CharSearch(CharSearch {
294                            target,
295                            direction,
296                            placement,
297                        }),
298                    }))
299                } else {
300                    self.reset();
301                    NormalGrammarOutput::Unmatched
302                }
303            }
304            PendingPrefix::G => {
305                self.reset();
306                NormalGrammarOutput::Unmatched
307            }
308            PendingPrefix::Z => match token {
309                KeyToken::Char('t') => {
310                    self.command(NormalCommand::ViewportPosition(ViewportPosition::Top))
311                }
312                KeyToken::Char('z') => {
313                    self.command(NormalCommand::ViewportPosition(ViewportPosition::Center))
314                }
315                KeyToken::Char('b') => {
316                    self.command(NormalCommand::ViewportPosition(ViewportPosition::Bottom))
317                }
318                _ => {
319                    self.reset();
320                    NormalGrammarOutput::Unmatched
321                }
322            },
323        }
324    }
325
326    /// Emits a motion command using the pending count.
327    fn motion(&mut self, motion: Motion) -> NormalGrammarOutput {
328        let count = self.take_count().unwrap_or_default();
329        self.command(NormalCommand::Motion(Counted {
330            count,
331            item: motion,
332        }))
333    }
334
335    /// Emits a completed command and clears parser state.
336    const fn command(&mut self, command: NormalCommand) -> NormalGrammarOutput {
337        self.reset();
338        NormalGrammarOutput::Command(command)
339    }
340
341    /// Stores a character-search prefix.
342    const fn pending_char_search(
343        &mut self,
344        direction: CharSearchDirection,
345        placement: CharSearchPlacement,
346    ) -> NormalGrammarOutput {
347        self.pending = Some(PendingPrefix::CharSearch {
348            direction,
349            placement,
350        });
351        NormalGrammarOutput::Pending
352    }
353
354    /// Appends one count digit, saturating on overflow.
355    fn push_count_digit(&mut self, digit: usize) {
356        let next = self.count.map_or(digit, |count| {
357            count.get().saturating_mul(10).saturating_add(digit)
358        });
359        self.count = NonZeroUsize::new(next);
360    }
361
362    /// Takes and clears the pending count.
363    fn take_count(&mut self) -> Option<Count> {
364        self.count.take().map(Count::from)
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::{Counted, NormalCommand, NormalGrammar, NormalGrammarOutput};
371    use crate::vim::{
372        CharSearch, CharSearchDirection, CharSearchPlacement, ColumnMotion, KeyToken, LineAddress,
373        Motion, PageDirection, ViewportPosition, WordKind, motion::WordEndMotion,
374        search::SearchRepeatDirection,
375    };
376    use proptest::prelude::*;
377
378    fn feed_chars(grammar: &mut NormalGrammar, keys: &str) -> NormalGrammarOutput {
379        let mut output = NormalGrammarOutput::Pending;
380        for character in keys.chars() {
381            output = grammar.feed(KeyToken::Char(character));
382        }
383        output
384    }
385
386    #[test]
387    fn parses_counts_outside_relative_motions() {
388        let mut grammar = NormalGrammar::default();
389
390        assert_eq!(
391            feed_chars(&mut grammar, "10j"),
392            NormalGrammarOutput::Command(NormalCommand::Motion(Counted {
393                count: std::num::NonZeroUsize::new(10).unwrap().into(),
394                item: Motion::Down,
395            }))
396        );
397    }
398
399    #[test]
400    fn parses_line_addresses_with_count_aware_targets() {
401        let mut grammar = NormalGrammar::default();
402
403        assert_eq!(
404            feed_chars(&mut grammar, "100gg"),
405            NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(
406                Motion::LineAddress(LineAddress::Number(
407                    std::num::NonZeroUsize::new(100).unwrap()
408                ))
409            )))
410        );
411        assert_eq!(
412            feed_chars(&mut grammar, "G"),
413            NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(
414                Motion::LineAddress(LineAddress::LastNonBlank)
415            )))
416        );
417    }
418
419    #[test]
420    fn parses_character_search_target_as_motion_payload() {
421        let mut grammar = NormalGrammar::default();
422
423        assert_eq!(
424            grammar.feed(KeyToken::Char('f')),
425            NormalGrammarOutput::Pending
426        );
427        assert_eq!(
428            grammar.feed(KeyToken::Char('b')),
429            NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(Motion::CharSearch(
430                CharSearch {
431                    target: 'b',
432                    direction: CharSearchDirection::Forward,
433                    placement: CharSearchPlacement::OnMatch,
434                }
435            ))))
436        );
437    }
438
439    #[test]
440    fn parses_control_page_motions() {
441        let mut grammar = NormalGrammar::default();
442
443        assert_eq!(
444            grammar.feed(KeyToken::Ctrl('f')),
445            NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(Motion::Page(
446                PageDirection::Forward
447            ))))
448        );
449        assert_eq!(
450            grammar.feed(KeyToken::Ctrl('b')),
451            NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(Motion::Page(
452                PageDirection::Backward
453            ))))
454        );
455    }
456
457    #[test]
458    fn parses_viewport_position_commands() {
459        let mut grammar = NormalGrammar::default();
460
461        assert_eq!(
462            feed_chars(&mut grammar, "zt"),
463            NormalGrammarOutput::Command(NormalCommand::ViewportPosition(ViewportPosition::Top))
464        );
465        assert_eq!(
466            feed_chars(&mut grammar, "zz"),
467            NormalGrammarOutput::Command(NormalCommand::ViewportPosition(ViewportPosition::Center))
468        );
469        assert_eq!(
470            feed_chars(&mut grammar, "zb"),
471            NormalGrammarOutput::Command(NormalCommand::ViewportPosition(ViewportPosition::Bottom))
472        );
473    }
474
475    #[test]
476    fn parses_search_repeat_commands() {
477        let mut grammar = NormalGrammar::default();
478
479        assert_eq!(
480            grammar.feed(KeyToken::Char('n')),
481            NormalGrammarOutput::Command(NormalCommand::SearchRepeat(SearchRepeatDirection::Next))
482        );
483        assert_eq!(
484            grammar.feed(KeyToken::Char('N')),
485            NormalGrammarOutput::Command(NormalCommand::SearchRepeat(
486                SearchRepeatDirection::Previous
487            ))
488        );
489    }
490
491    #[test]
492    fn unsupported_viewport_position_resets_pending_prefix() {
493        let mut grammar = NormalGrammar::default();
494
495        assert_eq!(
496            grammar.feed(KeyToken::Char('z')),
497            NormalGrammarOutput::Pending
498        );
499        assert_eq!(
500            grammar.feed(KeyToken::Char('x')),
501            NormalGrammarOutput::Unmatched
502        );
503        assert_eq!(
504            grammar.feed(KeyToken::Char('j')),
505            NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(Motion::Down)))
506        );
507    }
508
509    proptest! {
510        #[test]
511        fn parses_relative_motion_counts_generically(count in 1usize..10_000, key in prop_oneof![
512            Just('h'),
513            Just('j'),
514            Just('k'),
515            Just('l'),
516            Just('w'),
517            Just('W'),
518            Just('b'),
519            Just('B'),
520            Just('e'),
521            Just('E'),
522            Just('$'),
523            Just('^'),
524        ]) {
525            let mut grammar = NormalGrammar::default();
526            let input = format!("{count}{key}");
527            let output = feed_chars(&mut grammar, &input);
528            let motion = match key {
529                'h' => Motion::Left,
530                'j' => Motion::Down,
531                'k' => Motion::Up,
532                'l' => Motion::Right,
533                'w' => Motion::WordForward(WordKind::Normal),
534                'W' => Motion::WordForward(WordKind::Big),
535                'b' => Motion::WordBackward(WordKind::Normal),
536                'B' => Motion::WordBackward(WordKind::Big),
537                'e' => Motion::WordEnd(WordEndMotion::Forward(WordKind::Normal)),
538                'E' => Motion::WordEnd(WordEndMotion::Forward(WordKind::Big)),
539                '$' => Motion::Column(ColumnMotion::LineEnd),
540                '^' => Motion::Column(ColumnMotion::FirstNonBlank),
541                _ => unreachable!("strategy only emits supported keys"),
542            };
543
544            prop_assert_eq!(
545                output,
546                NormalGrammarOutput::Command(NormalCommand::Motion(Counted {
547                    count: std::num::NonZeroUsize::new(count).unwrap().into(),
548                    item: motion,
549                }))
550            );
551        }
552
553        #[test]
554        fn char_search_target_is_not_reinterpreted_as_a_followup_motion(target in any::<char>()) {
555            let mut grammar = NormalGrammar::default();
556
557            prop_assert_eq!(grammar.feed(KeyToken::Char('f')), NormalGrammarOutput::Pending);
558            prop_assert_eq!(
559                grammar.feed(KeyToken::Char(target)),
560                NormalGrammarOutput::Command(NormalCommand::Motion(Counted::once(
561                    Motion::CharSearch(CharSearch {
562                        target,
563                        direction: CharSearchDirection::Forward,
564                        placement: CharSearchPlacement::OnMatch,
565                    })
566                )))
567            );
568        }
569    }
570}