Skip to main content

hjkl_vim/
count.rs

1//! Digit-prefix count accumulator for the vim grammar.
2//!
3//! Vim's count prefix: typing `5j` means "move down 5 lines". Digits
4//! accumulate until a non-digit key arrives, then the accumulated
5//! count is consumed by that key's action.
6//!
7//! Vim quirk: `0` is a digit only when the buffer is non-empty
8//! (so `10j` works but `0` alone is the LineStart motion). The host
9//! detects this case via [`CountAccumulator::try_accumulate`] returning
10//! `false` for a `0` with empty buffer, and routes the `0` through the
11//! keymap path as a motion key.
12
13/// Digit-prefix count accumulator for the vim grammar.
14///
15/// Tracks a running count as digits are typed. Resets when consumed.
16#[derive(Debug, Default, Clone, PartialEq, Eq)]
17pub struct CountAccumulator {
18    /// Accumulated count. `0` means "no count specified".
19    buffer: u32,
20}
21
22impl CountAccumulator {
23    /// Create a new, empty accumulator.
24    pub const fn new() -> Self {
25        Self { buffer: 0 }
26    }
27
28    /// True iff no digits have been accumulated.
29    pub const fn is_empty(&self) -> bool {
30        self.buffer == 0
31    }
32
33    /// Peek at the current count without resetting. Returns 0 when empty.
34    pub const fn peek(&self) -> u32 {
35        self.buffer
36    }
37
38    /// Try to accumulate a digit character.
39    ///
40    /// Returns `true` if the digit was consumed; `false` otherwise —
41    /// either because `ch` is not an ASCII digit, OR because it's `0`
42    /// with an empty buffer (vim's LineStart-vs-digit-0 split). The
43    /// caller routes `false` results through the keymap.
44    ///
45    /// Saturates at `u32::MAX` to guard pathological input.
46    pub fn try_accumulate(&mut self, ch: char) -> bool {
47        if !ch.is_ascii_digit() {
48            return false;
49        }
50        if ch == '0' && self.buffer == 0 {
51            return false;
52        }
53        let d = (ch as u8 - b'0') as u32;
54        self.buffer = self.buffer.saturating_mul(10).saturating_add(d);
55        true
56    }
57
58    /// Drain the buffer, returning the count or `default` if empty.
59    /// Resets state.
60    pub fn take_or(&mut self, default: u32) -> u32 {
61        let c = if self.buffer == 0 {
62            default
63        } else {
64            self.buffer
65        };
66        self.buffer = 0;
67        c
68    }
69
70    /// Reset the buffer without taking. Used when a non-chord-starter
71    /// key arrives and the digits need to be replayed elsewhere — call
72    /// [`drain_as_digits`] first if you need the chars.
73    pub fn reset(&mut self) {
74        self.buffer = 0;
75    }
76
77    /// Drain the buffer as the digit characters that were typed,
78    /// preserving order. Used by the host to replay digits into the
79    /// engine FSM when the next key is not a hjkl-vim binding (e.g.
80    /// engine still owns `p` / `u` / etc. and needs count via FSM).
81    ///
82    /// Resets state. Returns empty string when buffer is empty.
83    pub fn drain_as_digits(&mut self) -> String {
84        let s = if self.buffer == 0 {
85            String::new()
86        } else {
87            self.buffer.to_string()
88        };
89        self.buffer = 0;
90        s
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn new_is_empty() {
100        let acc = CountAccumulator::new();
101        assert!(acc.is_empty());
102        assert_eq!(acc.peek(), 0);
103    }
104
105    #[test]
106    fn try_accumulate_digit_increments() {
107        let mut acc = CountAccumulator::new();
108        assert!(acc.try_accumulate('5'));
109        assert_eq!(acc.peek(), 5);
110        assert!(!acc.is_empty());
111    }
112
113    #[test]
114    fn try_accumulate_zero_with_empty_buffer_returns_false() {
115        // vim quirk: `0` with empty buffer is LineStart, not a digit
116        let mut acc = CountAccumulator::new();
117        assert!(!acc.try_accumulate('0'));
118        assert!(acc.is_empty());
119    }
120
121    #[test]
122    fn try_accumulate_zero_with_non_empty_buffer_appends() {
123        // `10j` must work: '1' then '0' → buffer = 10
124        let mut acc = CountAccumulator::new();
125        assert!(acc.try_accumulate('1'));
126        assert!(acc.try_accumulate('0'));
127        assert_eq!(acc.peek(), 10);
128    }
129
130    #[test]
131    fn try_accumulate_non_digit_returns_false() {
132        let mut acc = CountAccumulator::new();
133        assert!(!acc.try_accumulate('j'));
134        assert!(!acc.try_accumulate(' '));
135        assert!(!acc.try_accumulate('g'));
136        assert!(acc.is_empty());
137    }
138
139    #[test]
140    fn take_or_drains_and_returns_count() {
141        let mut acc = CountAccumulator::new();
142        acc.try_accumulate('5');
143        assert_eq!(acc.take_or(1), 5);
144        // Buffer must be cleared after take.
145        assert!(acc.is_empty());
146        assert_eq!(acc.take_or(1), 1);
147    }
148
149    #[test]
150    fn take_or_returns_default_when_empty() {
151        let mut acc = CountAccumulator::new();
152        assert_eq!(acc.take_or(1), 1);
153        assert_eq!(acc.take_or(42), 42);
154    }
155
156    #[test]
157    fn drain_as_digits_returns_typed_chars_in_order() {
158        let mut acc = CountAccumulator::new();
159        acc.try_accumulate('1');
160        acc.try_accumulate('2');
161        acc.try_accumulate('3');
162        let s = acc.drain_as_digits();
163        assert_eq!(s, "123");
164        assert!(acc.is_empty());
165    }
166
167    #[test]
168    fn drain_as_digits_empty_returns_empty_string() {
169        let mut acc = CountAccumulator::new();
170        let s = acc.drain_as_digits();
171        assert_eq!(s, "");
172    }
173
174    #[test]
175    fn try_accumulate_saturates_on_overflow() {
176        // Push many '9's — should saturate at u32::MAX without panicking.
177        let mut acc = CountAccumulator::new();
178        for _ in 0..20 {
179            acc.try_accumulate('9');
180        }
181        assert_eq!(acc.peek(), u32::MAX);
182    }
183
184    #[test]
185    fn reset_clears_without_returning() {
186        let mut acc = CountAccumulator::new();
187        acc.try_accumulate('7');
188        assert!(!acc.is_empty());
189        acc.reset();
190        assert!(acc.is_empty());
191        assert_eq!(acc.peek(), 0);
192    }
193}