hjkl-vim 0.5.0

Vim modal state types and grammar primitives for the hjkl editor stack. Pre-1.0 churn.
Documentation
/// Pending-state machine for second-key chords. The umbrella stores
/// `Option<PendingState>`; when `Some`, it routes keys through `step`
/// instead of the keymap trie.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PendingState {
    Replace {
        count: usize,
    },
    /// `f<x>` / `F<x>` / `t<x>` / `T<x>` — find single char on current line.
    /// `forward` = direction (true for f/t, false for F/T).
    /// `till` = stop one char before target (true for t/T, false for f/F).
    Find {
        count: usize,
        forward: bool,
        till: bool,
    },
    /// `g<x>` — bare g-prefix chord in Normal / Visual mode. The app sets this
    /// after intercepting `g`; `step` routes the next `Key::Char(ch)` to
    /// `EngineCmd::AfterGChord { ch, count }`. `Key::Esc` cancels; any
    /// non-char key also cancels (mirrors the `Find` arm).
    AfterG {
        count: usize,
    },
    /// `z<x>` — bare z-prefix chord in Normal / Visual mode. The app sets this
    /// after intercepting `z`; `step` routes the next `Key::Char(ch)` to
    /// `EngineCmd::AfterZChord { ch, count }`. `Key::Esc` cancels; any
    /// non-char key also cancels (mirrors the `AfterG` arm).
    AfterZ {
        count: usize,
    },
    // 2c–2e variants land later.
}

/// One step of the reducer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
    /// Need more keys — keep accumulating with new state.
    Wait(PendingState),
    /// Run this engine command, then clear pending.
    Commit(crate::cmd::EngineCmd),
    /// Cancel pending (Esc, invalid char, etc.). No engine call.
    Cancel,
    /// Pending state didn't consume this key — host should route it
    /// normally (e.g. modifier-only key). Pending state stays alive.
    Forward,
}

/// `Key` is intentionally minimal — hjkl-vim should not depend on
/// crossterm. Hosts translate their native keys into this shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Key {
    Char(char),
    Esc,
    Enter,
    Backspace,
    Tab,
    // Add more variants only as later chunks require them.
}

pub fn step(state: PendingState, key: Key) -> Outcome {
    match state {
        PendingState::Replace { count } => match key {
            Key::Esc => Outcome::Cancel,
            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::ReplaceChar { ch, count }),
            Key::Enter => Outcome::Commit(crate::cmd::EngineCmd::ReplaceChar { ch: '\n', count }),
            _ => Outcome::Cancel,
        },
        PendingState::Find {
            count,
            forward,
            till,
        } => match key {
            Key::Esc => Outcome::Cancel,
            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::FindChar {
                ch,
                forward,
                till,
                count,
            }),
            // Any non-char key cancels (vim cancels f<non-char>).
            _ => Outcome::Cancel,
        },
        PendingState::AfterG { count } => match key {
            Key::Esc => Outcome::Cancel,
            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::AfterGChord { ch, count }),
            // Any non-char key cancels (mirrors Find arm).
            _ => Outcome::Cancel,
        },
        PendingState::AfterZ { count } => match key {
            Key::Esc => Outcome::Cancel,
            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::AfterZChord { ch, count }),
            // Any non-char key cancels (mirrors AfterG arm).
            _ => Outcome::Cancel,
        },
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cmd::EngineCmd;

    // ── AfterG reducer unit tests ────────────────────────────────────────────

    #[test]
    fn after_g_gg_commits() {
        let state = PendingState::AfterG { count: 1 };
        assert_eq!(
            step(state, Key::Char('g')),
            Outcome::Commit(EngineCmd::AfterGChord { ch: 'g', count: 1 })
        );
    }

    #[test]
    fn after_g_gv_commits() {
        let state = PendingState::AfterG { count: 1 };
        assert_eq!(
            step(state, Key::Char('v')),
            Outcome::Commit(EngineCmd::AfterGChord { ch: 'v', count: 1 })
        );
    }

    #[test]
    fn after_g_gu_operator_commits() {
        // gU still produces AfterGChord; the engine handles the Pending::Op transition.
        let state = PendingState::AfterG { count: 1 };
        assert_eq!(
            step(state, Key::Char('U')),
            Outcome::Commit(EngineCmd::AfterGChord { ch: 'U', count: 1 })
        );
    }

    #[test]
    fn after_g_gi_commits() {
        let state = PendingState::AfterG { count: 1 };
        assert_eq!(
            step(state, Key::Char('i')),
            Outcome::Commit(EngineCmd::AfterGChord { ch: 'i', count: 1 })
        );
    }

    #[test]
    fn after_g_esc_cancels() {
        let state = PendingState::AfterG { count: 1 };
        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
    }

    #[test]
    fn after_g_count_carry_through() {
        // 5gg enters with count=5 — AfterGChord carries it through.
        let state = PendingState::AfterG { count: 5 };
        assert_eq!(
            step(state, Key::Char('g')),
            Outcome::Commit(EngineCmd::AfterGChord { ch: 'g', count: 5 })
        );
    }

    #[test]
    fn after_g_non_char_cancels() {
        // Non-char, non-Esc key (e.g. Enter) cancels.
        let state = PendingState::AfterG { count: 1 };
        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
    }

    // ── AfterZ reducer unit tests ────────────────────────────────────────────

    #[test]
    fn after_z_zz_commits() {
        let state = PendingState::AfterZ { count: 1 };
        assert_eq!(
            step(state, Key::Char('z')),
            Outcome::Commit(EngineCmd::AfterZChord { ch: 'z', count: 1 })
        );
    }

    #[test]
    fn after_z_zf_commits() {
        let state = PendingState::AfterZ { count: 1 };
        assert_eq!(
            step(state, Key::Char('f')),
            Outcome::Commit(EngineCmd::AfterZChord { ch: 'f', count: 1 })
        );
    }

    #[test]
    fn after_z_esc_cancels() {
        let state = PendingState::AfterZ { count: 1 };
        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
    }

    #[test]
    fn after_z_count_carry_through() {
        // 3zz enters with count=3 — AfterZChord carries it through.
        let state = PendingState::AfterZ { count: 3 };
        assert_eq!(
            step(state, Key::Char('z')),
            Outcome::Commit(EngineCmd::AfterZChord { ch: 'z', count: 3 })
        );
    }

    #[test]
    fn after_z_non_char_cancels() {
        // Non-char, non-Esc key (e.g. Enter) cancels.
        let state = PendingState::AfterZ { count: 1 };
        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
    }
}