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