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    /// Vim caps counts at 999,999,999 (`:h count`); larger prefixes clamp
39    /// here so downstream `count * n` math can't overflow.
40    pub const MAX_COUNT: u32 = 999_999_999;
41
42    /// Try to accumulate a digit character.
43    ///
44    /// Returns `true` if the digit was consumed; `false` otherwise —
45    /// either because `ch` is not an ASCII digit, OR because it's `0`
46    /// with an empty buffer (vim's LineStart-vs-digit-0 split). The
47    /// caller routes `false` results through the keymap.
48    ///
49    /// Clamps at [`Self::MAX_COUNT`] to guard pathological input.
50    pub fn try_accumulate(&mut self, ch: char) -> bool {
51        if !ch.is_ascii_digit() {
52            return false;
53        }
54        if ch == '0' && self.buffer == 0 {
55            return false;
56        }
57        let d = (ch as u8 - b'0') as u32;
58        self.buffer = self
59            .buffer
60            .saturating_mul(10)
61            .saturating_add(d)
62            .min(Self::MAX_COUNT);
63        true
64    }
65
66    /// Drain the buffer, returning the count or `default` if empty.
67    /// Resets state.
68    pub fn take_or(&mut self, default: u32) -> u32 {
69        let c = if self.buffer == 0 {
70            default
71        } else {
72            self.buffer
73        };
74        self.buffer = 0;
75        c
76    }
77
78    /// Reset the buffer without taking. Used when a non-chord-starter
79    /// key arrives and the digits need to be replayed elsewhere — call
80    /// [`drain_as_digits`] first if you need the chars.
81    pub fn reset(&mut self) {
82        self.buffer = 0;
83    }
84
85    /// Drain the buffer as the digit characters that were typed,
86    /// preserving order. Used by the host to replay digits into the
87    /// engine FSM when the next key is not a hjkl-vim binding (e.g.
88    /// engine still owns `p` / `u` / etc. and needs count via FSM).
89    ///
90    /// Resets state. Returns empty string when buffer is empty.
91    pub fn drain_as_digits(&mut self) -> String {
92        let s = if self.buffer == 0 {
93            String::new()
94        } else {
95            self.buffer.to_string()
96        };
97        self.buffer = 0;
98        s
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn new_is_empty() {
108        let acc = CountAccumulator::new();
109        assert!(acc.is_empty());
110        assert_eq!(acc.peek(), 0);
111    }
112
113    #[test]
114    fn try_accumulate_digit_increments() {
115        let mut acc = CountAccumulator::new();
116        assert!(acc.try_accumulate('5'));
117        assert_eq!(acc.peek(), 5);
118        assert!(!acc.is_empty());
119    }
120
121    #[test]
122    fn try_accumulate_zero_with_empty_buffer_returns_false() {
123        // vim quirk: `0` with empty buffer is LineStart, not a digit
124        let mut acc = CountAccumulator::new();
125        assert!(!acc.try_accumulate('0'));
126        assert!(acc.is_empty());
127    }
128
129    #[test]
130    fn try_accumulate_zero_with_non_empty_buffer_appends() {
131        // `10j` must work: '1' then '0' → buffer = 10
132        let mut acc = CountAccumulator::new();
133        assert!(acc.try_accumulate('1'));
134        assert!(acc.try_accumulate('0'));
135        assert_eq!(acc.peek(), 10);
136    }
137
138    #[test]
139    fn try_accumulate_non_digit_returns_false() {
140        let mut acc = CountAccumulator::new();
141        assert!(!acc.try_accumulate('j'));
142        assert!(!acc.try_accumulate(' '));
143        assert!(!acc.try_accumulate('g'));
144        assert!(acc.is_empty());
145    }
146
147    #[test]
148    fn take_or_drains_and_returns_count() {
149        let mut acc = CountAccumulator::new();
150        acc.try_accumulate('5');
151        assert_eq!(acc.take_or(1), 5);
152        // View must be cleared after take.
153        assert!(acc.is_empty());
154        assert_eq!(acc.take_or(1), 1);
155    }
156
157    #[test]
158    fn take_or_returns_default_when_empty() {
159        let mut acc = CountAccumulator::new();
160        assert_eq!(acc.take_or(1), 1);
161        assert_eq!(acc.take_or(42), 42);
162    }
163
164    #[test]
165    fn drain_as_digits_returns_typed_chars_in_order() {
166        let mut acc = CountAccumulator::new();
167        acc.try_accumulate('1');
168        acc.try_accumulate('2');
169        acc.try_accumulate('3');
170        let s = acc.drain_as_digits();
171        assert_eq!(s, "123");
172        assert!(acc.is_empty());
173    }
174
175    #[test]
176    fn drain_as_digits_empty_returns_empty_string() {
177        let mut acc = CountAccumulator::new();
178        let s = acc.drain_as_digits();
179        assert_eq!(s, "");
180    }
181
182    #[test]
183    fn try_accumulate_clamps_at_vim_max_count() {
184        // Push many '9's — must clamp at vim's 999,999,999 count cap
185        // (`:h count`) without panicking.
186        let mut acc = CountAccumulator::new();
187        for _ in 0..20 {
188            acc.try_accumulate('9');
189        }
190        assert_eq!(acc.peek(), CountAccumulator::MAX_COUNT);
191        // And drain_as_digits replays the clamped value, not 20 nines.
192        assert_eq!(acc.drain_as_digits(), "999999999");
193    }
194
195    #[test]
196    fn reset_clears_without_returning() {
197        let mut acc = CountAccumulator::new();
198        acc.try_accumulate('7');
199        assert!(!acc.is_empty());
200        acc.reset();
201        assert!(acc.is_empty());
202        assert_eq!(acc.peek(), 0);
203    }
204}