Skip to main content

hjkl_vim/vim/
state.rs

1//! Vim FSM: state.
2//!
3//! Split out of the monolithic `vim.rs` (#267 follow-up).
4
5use hjkl_vim_types::{
6    InsertSession, LastChange, LastHorizontalMotion, LastVisual, Mode, Operator, Pending,
7};
8
9use hjkl_engine::VimMode;
10
11/// Vim caps count prefixes at 999,999,999 (`:h count`); mirror that cap on
12/// every value that can feed a `0..count` apply loop so a pathological digit
13/// stream (`<20 nines>x`) saturating toward `usize::MAX` can't freeze the
14/// editor. Matches `CountAccumulator::MAX_COUNT` in hjkl-vim.
15pub const MAX_COUNT: usize = 999_999_999;
16/// ROT13 a string: rotate ASCII letters by 13, leave everything else.
17pub fn rot13_str(s: &str) -> String {
18    s.chars()
19        .map(|c| match c {
20            'a'..='z' => (((c as u8 - b'a' + 13) % 26) + b'a') as char,
21            'A'..='Z' => (((c as u8 - b'A' + 13) % 26) + b'A') as char,
22            _ => c,
23        })
24        .collect()
25}
26#[derive(Debug, Default)]
27pub struct VimState {
28    /// Internal FSM mode. Kept in sync with `current_mode` after every
29    /// `step`. Phase 6.6b: promoted from private to `pub` so the FSM
30    /// body (moving to hjkl-vim in 6.6c–6.6g) can read/write it directly
31    /// until the migration is complete.
32    pub mode: Mode,
33    /// Two-key chord in progress. `Pending::None` when idle.
34    pub pending: Pending,
35    /// Digit prefix accumulated before an operator or motion. `0` means
36    /// no prefix was typed (treated as 1 by most commands).
37    pub count: usize,
38    /// Last `f`/`F`/`t`/`T` target, for `;` / `,` repeat.
39    pub last_find: Option<(char, bool, bool)>,
40    /// Transient: set while resolving a `;` / `,` repeat so the following
41    /// `t`/`T` find skips an immediately-adjacent match (vim's repeat quirk).
42    /// Read and cleared by the `Motion::Find` cursor dispatch.
43    pub find_repeat_skip: bool,
44    /// Most-recent mutating command for `.` dot-repeat.
45    pub last_change: Option<LastChange>,
46    /// Captured on insert-mode entry: count, buffer snapshot, entry kind.
47    pub insert_session: Option<InsertSession>,
48    /// (row, col) anchor for char-wise Visual mode. Set on entry, used
49    /// to compute the highlight range and the operator range without
50    /// relying on tui-textarea's live selection.
51    pub visual_anchor: (usize, usize),
52    /// Row anchor for VisualLine mode.
53    pub visual_line_anchor: usize,
54    /// (row, col) anchor for VisualBlock mode. The live cursor is the
55    /// opposite corner.
56    pub block_anchor: (usize, usize),
57    /// Intended "virtual" column for the block's active corner. j/k
58    /// clamp cursor.col to shorter rows, which would collapse the
59    /// block across ragged content — so we remember the desired column
60    /// separately and use it for block bounds / insert-column
61    /// computations. Updated by h/l only.
62    pub block_vcol: usize,
63    /// `$` pressed in VisualBlock (`:h v_b_$`): makes the block "ragged" —
64    /// every row resolves its own right edge to that row's own EOL
65    /// instead of the block's fixed `right` column. Set by
66    /// `update_block_vcol` on `Motion::LineEnd`; cleared by any OTHER
67    /// horizontal motion that re-establishes a fixed column. Vertical
68    /// motions (j/k/gg/G/…) preserve it, matching vim. Reset to `false`
69    /// on every fresh `<C-v>` entry.
70    pub block_to_eol: bool,
71    /// Track whether the last yank/cut was linewise (drives `p`/`P` layout).
72    /// Active register selector — set by `"reg` prefix, consumed by
73    /// the next y / d / c / p. `None` falls back to the unnamed `"`.
74    pub pending_register: Option<char>,
75    /// Recording target — set by `q{reg}`, cleared by a bare `q`.
76    /// While `Some`, every consumed `Input` is appended to
77    /// `recording_keys`.
78    pub recording_macro: Option<char>,
79    /// Keys recorded into the in-progress macro. On `q` finish, these
80    /// are encoded via [`hjkl_engine::input::encode_macro`] and written to
81    /// the matching named register slot, so macros and yanks share a
82    /// single store.
83    pub recording_keys: Vec<hjkl_engine::input::Input>,
84    /// Set during `@reg` replay so the recorder doesn't capture the
85    /// replayed keystrokes a second time.
86    pub replaying_macro: bool,
87    /// Last register played via `@reg`. `@@` re-plays this one.
88    pub last_macro: Option<char>,
89    /// Position where the cursor was when insert mode last exited (Esc).
90    /// Used by `gi` to return to the exact (row, col) where the user
91    /// last typed, matching vim's `:h gi`.
92    pub last_insert_pos: Option<(usize, usize)>,
93    /// Snapshot of the last visual selection for `gv` re-entry.
94    /// Stored on every Visual / VisualLine / VisualBlock exit.
95    pub last_visual: Option<LastVisual>,
96    /// `zz` / `zt` / `zb` set this so the end-of-step scrolloff
97    /// pass doesn't override the user's explicit viewport pinning.
98    /// Cleared every step.
99    /// Set by the 7 smooth-scrollable motions (C-d/u/f/b, zz/zt/zb) so the
100    /// app can animate the viewport jump. Drained via Editor::take_scroll_anim_hint.
101    /// Set while replaying `.` / last-change so we don't re-record it.
102    pub replaying: bool,
103    /// Entered Normal from Insert via `Ctrl-o`; after the next complete
104    /// normal-mode command we return to Insert.
105    pub one_shot_normal: bool,
106    /// Live `/` or `?` prompt. `None` outside search-prompt mode.
107    /// Most recent committed search pattern. Surfaced to host apps via
108    /// [`Editor::last_search`] so their status line can render a hint
109    /// and so `n` / `N` have something to repeat.
110    /// Direction of the last committed search. `n` repeats this; `N`
111    /// inverts it. Defaults to forward so a never-searched buffer's
112    /// `n` still walks downward.
113    /// Text of the most recent insert session — vim's `".` register, pasted
114    /// via `<C-r>.` in insert mode (and `".p` in normal mode).
115    pub last_insert_text: Option<String>,
116    /// Back half of the jumplist — `Ctrl-o` pops from here. Populated
117    /// with the pre-motion cursor when a "big jump" motion fires
118    /// (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, committed `/` or
119    /// `?`). Capped at 100 entries.
120    /// Forward half — `Ctrl-i` pops from here. Cleared by any new big
121    /// jump, matching vim's "branch off trims forward history" rule.
122    /// Set by `Ctrl-R` in insert mode while waiting for the register
123    /// selector. The next typed char names the register; its contents
124    /// are inserted inline at the cursor and the flag clears.
125    pub insert_pending_register: bool,
126    /// Stashed start position for the `[` mark on a Change operation.
127    /// Set to `top` before the cut in `run_operator_over_range` (Change
128    /// arm); consumed by `finish_insert_session` on Esc-from-insert
129    /// when the reason is `AfterChange`. Mirrors vim's `:h '[` / `:h ']`
130    /// rule that `[` = start of change, `]` = last typed char on exit.
131    pub change_mark_start: Option<(usize, usize)>,
132    /// Bounded history of committed `/` / `?` search patterns. Newest
133    /// entries are at the back; capped at [`SEARCH_HISTORY_MAX`] to
134    /// avoid unbounded growth on long sessions.
135    /// Index into `search_history` while the user walks past patterns
136    /// in the prompt via `Ctrl-P` / `Ctrl-N`. `None` outside that walk
137    /// — typing or backspacing in the prompt resets it so the next
138    /// `Ctrl-P` starts from the most recent entry again.
139    /// Wall-clock instant of the last keystroke. Drives the
140    /// `:set timeoutlen` multi-key timeout — if `now() - last_input_at`
141    /// exceeds the configured budget, any pending prefix is cleared
142    /// before the new key dispatches. `None` before the first key.
143    /// 0.0.29 (Patch B): `:set timeoutlen` math now reads
144    /// [`hjkl_engine::types::Host::now`] via `last_input_host_at`. This
145    /// `Instant`-flavoured field stays for snapshot tests that still
146    /// observe it directly.
147    /// `Host::now()` reading at the last keystroke. Drives
148    /// `:set timeoutlen` so macro replay / headless drivers stay
149    /// deterministic regardless of wall-clock skew.
150    /// Canonical current mode. Mirrors `mode` (the FSM-internal field)
151    /// AND is written by every Phase 6.3 primitive (`set_mode`,
152    /// `enter_visual_char_bridge`, …). Once the FSM is gone this is the
153    /// sole source of truth; until then both fields are kept in sync.
154    /// Initialized to `Normal` via `#[derive(Default)]`.
155    pub current_mode: hjkl_engine::VimMode,
156    /// Most recent successful :s invocation. Stored so :& / :&& can repeat it.
157    /// Stack of auto-inserted closing characters awaiting skip-over.
158    ///
159    /// Each entry `(row, col, ch)` records where autopair placed a close
160    /// character. When the next typed char matches `ch` AND the cursor is
161    /// immediately before that position, the engine advances past it
162    /// ("skip-over") instead of inserting. The stack is cleared on any
163    /// cursor motion, mode change, or out-of-pair edit.
164    /// Last sneak digraph and direction: `Some(((c1, c2), forward))`.
165    /// Used by `;` / `,` sneak-repeat when `last_horizontal_motion == Sneak`.
166    pub last_sneak: Option<((char, char), bool)>,
167    /// Tracks which kind of horizontal motion was last performed, so `;` / `,`
168    /// can dispatch to sneak-repeat vs. find-char-repeat as appropriate.
169    pub last_horizontal_motion: LastHorizontalMotion,
170}
171impl VimState {
172    pub fn public_mode(&self) -> VimMode {
173        match self.mode {
174            Mode::Normal => VimMode::Normal,
175            Mode::Insert => VimMode::Insert,
176            Mode::Visual => VimMode::Visual,
177            Mode::VisualLine => VimMode::VisualLine,
178            Mode::VisualBlock => VimMode::VisualBlock,
179        }
180    }
181
182    /// Project the current vim mode onto the discipline-agnostic
183    /// [`hjkl_engine::CoarseMode`] (the seam app chrome reads — #265 G3 / #267).
184    /// Shared by [`hjkl_engine::DisciplineState::coarse_mode`] and
185    /// [`hjkl_engine::Editor::coarse_mode`].
186    pub fn coarse_mode(&self) -> hjkl_engine::CoarseMode {
187        use hjkl_engine::CoarseMode;
188        match self.current_mode {
189            VimMode::Normal => CoarseMode::Normal,
190            VimMode::Insert => CoarseMode::Insert,
191            VimMode::Visual => CoarseMode::Select,
192            VimMode::VisualLine => CoarseMode::SelectLine,
193            VimMode::VisualBlock => CoarseMode::SelectBlock,
194        }
195    }
196
197    pub fn force_normal(&mut self) {
198        self.mode = Mode::Normal;
199        self.pending = Pending::None;
200        self.count = 0;
201        self.insert_session = None;
202        // Phase 6.3: keep current_mode in sync for callers that bypass step().
203        self.current_mode = hjkl_engine::VimMode::Normal;
204    }
205
206    /// Reset every prefix-tracking field so the next keystroke starts
207    /// a fresh sequence. Drives `:set timeoutlen` — when the user
208    /// pauses past the configured budget, `hjkl_vim::dispatch_input` calls
209    /// this before dispatching the new key.
210    ///
211    /// Resets: `pending`, `count`, `pending_register`,
212    /// `insert_pending_register`. Does NOT touch `mode`,
213    /// `insert_session`, marks, jump list, or visual anchors —
214    /// those aren't part of the in-flight chord.
215    pub fn clear_pending_prefix(&mut self) {
216        self.pending = Pending::None;
217        self.count = 0;
218        self.pending_register = None;
219        self.insert_pending_register = false;
220    }
221
222    /// Widen the active insert session's row window to include `row`. Called
223    /// by the Phase 6.1 public `Editor::insert_*` methods after each
224    /// mutation so `finish_insert_session` diffs the right range on Esc.
225    /// No-op when no insert session is active (e.g. calling from Normal mode).
226    pub(crate) fn widen_insert_row(&mut self, row: usize) {
227        if let Some(ref mut session) = self.insert_session {
228            session.row_min = session.row_min.min(row);
229            session.row_max = session.row_max.max(row);
230        }
231    }
232
233    pub fn is_visual(&self) -> bool {
234        matches!(
235            self.mode,
236            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
237        )
238    }
239
240    pub fn is_visual_char(&self) -> bool {
241        self.mode == Mode::Visual
242    }
243
244    /// The pending repeat count (typed digits before a motion/operator),
245    /// or `None` when no digits are pending. Zero is treated as absent.
246    pub(crate) fn pending_count_val(&self) -> Option<u32> {
247        if self.count == 0 {
248            None
249        } else {
250            Some(self.count as u32)
251        }
252    }
253
254    /// `true` when an in-flight chord is awaiting more keys. Inverse of
255    /// `matches!(self.pending, Pending::None)`.
256    pub(crate) fn is_chord_pending(&self) -> bool {
257        !matches!(self.pending, Pending::None)
258    }
259
260    /// Return a single char representing the pending operator, if any.
261    /// Used by host apps (status line "showcmd" area) to display e.g.
262    /// `d`, `y`, `c` while waiting for a motion.
263    pub(crate) fn pending_op_char(&self) -> Option<char> {
264        let op = match &self.pending {
265            Pending::Op { op, .. }
266            | Pending::OpTextObj { op, .. }
267            | Pending::OpG { op, .. }
268            | Pending::OpFind { op, .. }
269            | Pending::OpSquareBracketOpen { op, .. }
270            | Pending::OpSquareBracketClose { op, .. } => Some(*op),
271            _ => None,
272        };
273        op.map(|o| match o {
274            Operator::Delete => 'd',
275            Operator::Change => 'c',
276            Operator::Yank => 'y',
277            Operator::Uppercase => 'U',
278            Operator::Lowercase => 'u',
279            Operator::ToggleCase => '~',
280            Operator::Indent => '>',
281            Operator::Outdent => '<',
282            Operator::Fold => 'z',
283            Operator::Reflow => 'q',
284            Operator::ReflowKeepCursor => 'w',
285            Operator::AutoIndent => '=',
286            Operator::Filter => '!',
287            // `gc` prefix — doubled as `gcc`.
288            Operator::Comment => 'c',
289            // `g?` prefix — doubled as `g??`.
290            Operator::Rot13 => '?',
291        })
292    }
293}
294/// The vim FSM state is the vim discipline's [`hjkl_engine::DisciplineState`]: the
295/// engine reaches it type-erased and asks only for its [`hjkl_engine::CoarseMode`]
296/// (#265 G3 / #267). Until `VimState` physically moves into `hjkl-vim`, this
297/// impl lives here alongside the struct.
298impl hjkl_engine::DisciplineState for VimState {
299    fn coarse_mode(&self) -> hjkl_engine::CoarseMode {
300        Self::coarse_mode(self)
301    }
302    /// Vim's idle state is Normal mode with no pending chord, count or insert
303    /// session — exactly what `force_normal` establishes.
304    fn reset_to_idle(&mut self) {
305        self.force_normal();
306    }
307    /// Only the FSM mode — `current_mode`, `pending`, `count` and any open
308    /// insert session are deliberately left alone (see the trait docs).
309    fn reset_mode_after_history(&mut self) {
310        self.mode = Mode::Normal;
311    }
312    fn as_any(&self) -> &dyn std::any::Any {
313        self
314    }
315    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
316        self
317    }
318}