hjkl-vim 0.44.0

Vim modal state types and grammar primitives for the hjkl editor stack. Pre-1.0 churn.
Documentation
//! Vim FSM: dot repeat.
//!
//! Split out of the monolithic `vim.rs` (#267 follow-up).

use hjkl_vim_types::{InsertEntry, LastChange, Mode, VisualExtent};

use super::*;
use crate::vim_state::{vim, vim_mut};
use hjkl_engine::Editor;
use hjkl_engine::buf_helpers::{buf_cursor_pos, buf_line, buf_line_chars, buf_set_cursor_rc};

/// Replay-side helper: type `text` at the cursor, then leave insert mode
/// (the original change ended with Esc, so the dot-repeat must end the same
/// way — including the cursor step-back vim does on Esc-from-insert).
pub fn replay_insert_and_finish<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    text: &str,
) {
    // Typed again, so it wraps at 'textwidth' where its own text reaches
    // the margin, as vim's redo does.
    type_text(ed, text);
    if vim_mut(ed).insert_session.take().is_some() {
        if ed.cursor().1 > 0 {
            hjkl_engine::motions::move_left(ed.buffer_mut(), 1);
        }
        vim_mut(ed).mode = Mode::Normal;
        // The live `<Esc>` sets the curswant here too.
        remember_cursor_column(ed);
    }
}
pub fn replay_last_change<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    outer_count: usize,
) {
    let Some(change) = vim(ed).last_change.clone() else {
        return;
    };
    vim_mut(ed).replaying = true;
    // Dot-repeat with an explicit `[count].` *replaces* the change's stored
    // count (`:h .`): `3x` then `2.` deletes 2, not 6. `outer_count == 0`
    // means the user typed no count, so the original stored count is reused.
    // Both counts are individually capped; re-clamp at vim's ceiling
    // (`:h count`) so replay loops stay bounded.
    let explicit = if outer_count > 0 {
        Some(outer_count.min(MAX_COUNT))
    } else {
        None
    };
    let scaled = |c: usize| explicit.unwrap_or(c).min(MAX_COUNT);
    match change {
        LastChange::OpMotion {
            op,
            motion,
            count,
            inserted,
            register,
        } => {
            let total = scaled(count.max(1));
            vim_mut(ed).pending_register = register;
            // `.` re-runs the typed `cw` / `cW`, so whether it changes to the
            // word's end or, from a blank, like `dw` is decided afresh at
            // the cursor it now starts from (`:h cw`).
            let typed = match motion {
                Motion::ChangeWordEnd { big: false } => Motion::WordFwd,
                Motion::ChangeWordEnd { big: true } => Motion::BigWordFwd,
                m => m,
            };
            let motion = crate::vim::operator::change_word_motion(
                typed,
                op,
                ed.char_at_cursor().is_some_and(|c| !c.is_whitespace()),
            );
            apply_op_with_motion(ed, op, &motion, total);
            if let Some(text) = inserted {
                replay_insert_and_finish(ed, &text);
            }
        }
        LastChange::OpTextObj {
            op,
            obj,
            inner,
            inserted,
            register,
        } => {
            // Dot-repeat replays the text object at count 1 (the original
            // count is not retained in `LastChange::OpTextObj`).
            vim_mut(ed).pending_register = register;
            apply_op_with_text_object(ed, op, obj, inner, 1);
            if let Some(text) = inserted {
                replay_insert_and_finish(ed, &text);
            }
        }
        LastChange::OpMark {
            op,
            ch,
            linewise,
            inserted,
            register,
        } => {
            vim_mut(ed).pending_register = register;
            apply_op_mark(ed, op, ch, linewise);
            if let Some(text) = inserted {
                replay_insert_and_finish(ed, &text);
            }
        }
        LastChange::LineOp {
            op,
            count,
            inserted,
            register,
        } => {
            let total = scaled(count.max(1));
            // Restore the original change's explicit register (if any) so
            // `"add.` deletes into `"a` again, not the unnamed register
            // (`:h redo-register`) — `execute_line_op` consumes
            // `pending_register` internally, same as the live FSM path.
            vim_mut(ed).pending_register = register;
            execute_line_op(ed, op, total);
            if let Some(text) = inserted {
                replay_insert_and_finish(ed, &text);
            }
        }
        LastChange::CharDel {
            forward,
            count,
            register,
        } => {
            // `"axl.` deletes into `"a` again — `do_char_delete` consumes
            // `pending_register` itself, same as the live FSM path.
            vim_mut(ed).pending_register = register;
            do_char_delete(ed, forward, scaled(count));
        }
        LastChange::ReplaceChar { ch, count } => {
            replace_char(ed, ch, scaled(count));
        }
        LastChange::ToggleCase { count } => {
            toggle_case_at_cursor_bridge(ed, scaled(count));
        }
        LastChange::JoinLine { count } => {
            for _ in 0..scaled(count) {
                ed.push_undo();
                if !join_line(ed) {
                    break;
                }
            }
        }
        LastChange::Paste {
            before,
            count,
            cursor_after,
            reindent,
            register,
        } => {
            vim_mut(ed).pending_register = register;
            do_paste(ed, before, scaled(count), cursor_after, reindent);
        }
        LastChange::GnOp {
            op,
            forward,
            inserted,
            register,
        } => {
            vim_mut(ed).pending_register = register;
            gn_operate(ed, Some(op), forward, 1);
            if let Some(text) = inserted {
                replay_insert_and_finish(ed, &text);
            }
        }
        LastChange::VisualOp {
            op,
            extent,
            inserted,
            register,
            levels,
        } => {
            vim_mut(ed).pending_register = register;
            // B1: replay a visual-mode operator over a same-SIZE region
            // anchored at the CURRENT cursor (`:h v_.`), not the original
            // absolute range. `Line` extents are exactly `[count]dd`-style
            // (execute_line_op already implements that count semantics for
            // every operator it supports). `Char` extents synthesize an
            // inclusive charwise range: single-line selections keep their
            // raw width from the cursor; multi-line selections run the
            // first line cursor-to-EOL, middle lines whole, and the last
            // line's first `width` chars — the same shape
            // `run_operator_over_range` already cuts for a live multi-line
            // charwise visual selection.
            match extent {
                VisualExtent::Line { lines } => {
                    // vim's Visual redo moves the end down by the saved line
                    // count and clamps it to the buffer's last line; unlike
                    // `[count]dd` it never aborts for lack of lines.
                    let fit = last_content_row(ed).saturating_sub(ed.cursor().0) + 1;
                    execute_line_op_from_cursor(ed, op, lines.min(fit), levels);
                    if let Some(text) = inserted {
                        replay_insert_and_finish(ed, &text);
                    }
                }
                VisualExtent::Char { lines, width } => {
                    let (r, c) = ed.cursor();
                    let end = if lines <= 1 {
                        (r, c + width.saturating_sub(1))
                    } else {
                        (r + lines - 1, width.saturating_sub(1))
                    };
                    let (from, to, kind) = charwise_visual_range(ed, (r, c), end);
                    run_operator_over_range(ed, op, from, to, kind);
                    if let Some(text) = inserted {
                        replay_insert_and_finish(ed, &text);
                    }
                }
                // B-block (`:h v_.` for blocks): reconstruct a same-SIZE
                // rectangle top-left at the cursor and re-run the op. Block
                // `c` re-inserts `inserted` itself (it never went through
                // insert mode here), so DON'T also `replay_insert_and_finish`.
                VisualExtent::Block { rows, cols, to_eol } => {
                    replay_block_visual_op(ed, op, rows, cols, to_eol, inserted, levels);
                }
            }
        }
        LastChange::VisualReplace { ch, extent } => {
            replay_visual_replace(ed, ch, extent);
        }
        LastChange::VisualBlockReplace {
            ch,
            rows,
            cols,
            to_eol,
        } => {
            replay_block_replace(ed, ch, rows, cols, to_eol);
        }
        LastChange::VisualBlockInsert {
            text,
            rows,
            cols,
            to_eol,
            append,
        } => {
            replay_block_insert(ed, &text, rows, cols, to_eol, append);
        }
        LastChange::ReplaceMode { text } => {
            if text.is_empty() {
                remember_cursor_column(ed);
                vim_mut(ed).replaying = false;
                return;
            }
            use hjkl_buffer::{Edit, MotionKind, Position};
            ed.push_undo();
            for ch in text.chars() {
                let cursor = buf_cursor_pos(ed.buffer());
                if ch == '\n' {
                    // `<CR>` in Replace mode splits the line; it overtypes
                    // nothing.
                    ed.mutate_edit(Edit::InsertStr {
                        at: cursor,
                        text: "\n".to_string(),
                    });
                    buf_set_cursor_rc(ed.buffer_mut(), cursor.row + 1, 0);
                    continue;
                }
                let line_chars = buf_line_chars(ed.buffer(), cursor.row);
                if cursor.col < line_chars {
                    // Overtype the char under the cursor.
                    ed.mutate_edit(Edit::DeleteRange {
                        start: cursor,
                        end: Position::new(cursor.row, cursor.col + 1),
                        kind: MotionKind::Char,
                    });
                }
                ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
                buf_set_cursor_rc(ed.buffer_mut(), cursor.row, cursor.col + 1);
            }
            // Esc step-back onto the last overtyped char.
            if ed.cursor().1 > 0 {
                hjkl_engine::motions::move_left(ed.buffer_mut(), 1);
            }
            remember_cursor_column(ed);
        }
        LastChange::DeleteToEol { inserted, register } => {
            ed.push_undo();
            vim_mut(ed).pending_register = register;
            delete_to_eol(ed);
            if let Some(text) = inserted {
                // `C`: retype the text, then step back onto its last
                // character as the typed `<Esc>` did.
                type_text(ed, &text);
                if ed.cursor().1 > 0 {
                    hjkl_engine::motions::move_left(ed.buffer_mut(), 1);
                }
            } else {
                // `D` lands on the new last character, as the live one does.
                hjkl_engine::motions::move_left(ed.buffer_mut(), 1);
            }
            remember_cursor_column(ed);
        }
        LastChange::OpenLine {
            above,
            inserted,
            count,
        } => {
            // Open the line the way the typed `o` / `O` did — its indent and
            // comment leader included — then retype what was typed into it,
            // once per counted line.
            if above {
                open_line_above_bridge(ed, 1);
            } else {
                open_line_below_bridge(ed, 1);
            }
            let (row, col) = ed.cursor();
            let leader: String = buf_line(ed.buffer(), row)
                .unwrap_or_default()
                .chars()
                .take(col)
                .collect();
            let text = inserted.clone() + &open_line_repeats(&leader, &inserted, scaled(count));
            replay_insert_and_finish(ed, &text);
        }
        LastChange::InsertAt {
            entry,
            inserted,
            count,
            backspaces,
        } => {
            ed.push_undo();
            match entry {
                InsertEntry::I => {}
                InsertEntry::ShiftI => move_to_insert_start(ed),
                InsertEntry::GShiftI => {
                    let row = ed.cursor().0;
                    ed.jump_cursor(row, 0);
                }
                InsertEntry::A => {
                    hjkl_engine::motions::move_right_to_end(ed.buffer_mut(), 1);
                }
                InsertEntry::ShiftA => {
                    hjkl_engine::motions::move_line_end(ed.buffer_mut());
                    hjkl_engine::motions::move_right_to_end(ed.buffer_mut(), 1);
                }
                #[cfg(not(hjkl_exhaustive))]
                _ => {}
            }
            for _ in 0..scaled(count).max(1) {
                for _ in 0..backspaces {
                    generic_backspace(ed);
                }
                type_text(ed, &inserted);
            }
            // The replay ends the way the typed insert did, with `<Esc>`
            // stepping back onto the last inserted character.
            if ed.cursor().1 > 0 {
                hjkl_engine::motions::move_left(ed.buffer_mut(), 1);
            }
            remember_cursor_column(ed);
        }
        #[cfg(not(hjkl_exhaustive))]
        _ => {}
    }
    vim_mut(ed).replaying = false;
}
/// The substring of `after` that differs from `before` (first-diff to
/// last-diff). Unlike [`extract_inserted`] this works for equal-length or
/// shorter results, so it captures `R` overstrike text for dot-repeat.
pub fn changed_run(before: &str, after: &str) -> String {
    let a: Vec<char> = before.chars().collect();
    let b: Vec<char> = after.chars().collect();
    let prefix = a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count();
    let max_suffix = a.len().min(b.len()) - prefix;
    let suffix = a
        .iter()
        .rev()
        .zip(b.iter().rev())
        .take(max_suffix)
        .take_while(|(x, y)| x == y)
        .count();
    b[prefix..b.len() - suffix].iter().collect()
}
pub fn extract_inserted(before: &str, after: &str) -> String {
    let before_chars: Vec<char> = before.chars().collect();
    let after_chars: Vec<char> = after.chars().collect();
    if after_chars.len() <= before_chars.len() {
        return String::new();
    }
    let prefix = before_chars
        .iter()
        .zip(after_chars.iter())
        .take_while(|(a, b)| a == b)
        .count();
    let max_suffix = before_chars.len() - prefix;
    let suffix = before_chars
        .iter()
        .rev()
        .zip(after_chars.iter().rev())
        .take(max_suffix)
        .take_while(|(a, b)| a == b)
        .count();
    after_chars[prefix..after_chars.len() - suffix]
        .iter()
        .collect()
}