alma 0.1.1

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Vim modal state component snapshots.

use crate::vim::{
    LeaderState, NormalState, RegisterBank, RegisterBankShape, RepeatState, RepeatStateShape,
    VimCommandState, VimMode, VimSearchState, VimSelectionState, VimStatusLine, VisualState,
};
use bevy::prelude::Component;
use std::fmt::{Debug, Formatter};

/// Entity-scoped Vim state for an editor view.
#[derive(Clone, Component, Default)]
pub struct VimModalState {
    /// Pending grammar state.
    pub grammar: VimGrammarState,
    /// View-local editor state.
    pub editor: VimEditorState,
}

/// Pending Vim grammars for one view.
#[derive(Clone, Debug, Default)]
pub struct VimGrammarState {
    /// Normal-mode parser state.
    pub normal: NormalState,
    /// Visual-mode parser state.
    pub visual: VisualState,
}

impl VimGrammarState {
    /// Clears every pending grammar prefix.
    pub const fn reset(&mut self) {
        self.normal.reset_grammar();
        self.visual.reset_grammar();
    }
}

/// View-local Vim editor state that is not grammar.
#[derive(Clone, Default)]
pub struct VimEditorState {
    /// Current normal/insert/visual mode.
    pub mode: VimMode,
    /// Active visual selection anchor.
    pub selection: VimSelectionState,
    /// Active or previous search state.
    pub search: VimSearchState,
    /// Active command-line state.
    pub command: VimCommandState,
    /// Pending or visible leader-key state.
    pub leader: LeaderState,
    /// Bottom status-line message.
    pub status: VimStatusLine,
    /// View-local Vim registers.
    pub registers: RegisterBank,
    /// View-local dot-repeat state.
    pub repeat: RepeatState,
}

impl Debug for VimEditorState {
    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("VimEditorState")
            .field("shape", &VimEditorStateShape::from(self))
            .finish()
    }
}

impl Debug for VimModalState {
    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("VimModalState")
            .field("shape", &VimModalStateShape::from(self))
            .finish()
    }
}

/// Redacted editor diagnostic shape.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VimEditorStateShape {
    /// Current normal/insert/visual mode.
    mode: VimMode,
    /// Redacted visual selection state.
    selection: SelectionShape,
    /// Redacted search prompt state.
    search: SearchShape,
    /// Redacted command prompt state.
    command: PromptShape,
    /// Redacted leader state.
    leader: LeaderShape,
    /// Redacted status state.
    status: StatusShape,
    /// Redacted register state.
    registers: RegisterBankShape,
    /// Redacted dot-repeat state.
    repeat: RepeatStateShape,
}

impl VimEditorStateShape {
    /// Builds a redacted diagnostic shape from editor state.
    #[must_use]
    pub fn from_editor_state(state: &VimEditorState) -> Self {
        Self::from(state)
    }
}

impl From<&VimEditorState> for VimEditorStateShape {
    fn from(state: &VimEditorState) -> Self {
        Self {
            mode: state.mode,
            selection: SelectionShape::from_active(state.selection.selection().is_some()),
            search: SearchShape {
                prompt: PromptShape::from_active(state.search.is_active()),
                previous: PreviousSearchShape::from_present(state.search.last_query().is_some()),
            },
            command: PromptShape::from_active(state.command.is_active()),
            leader: LeaderShape::from_state(&state.leader),
            status: StatusShape::from_present(state.status.message().is_some()),
            registers: RegisterBankShape::from_register_bank(&state.registers),
            repeat: RepeatStateShape::from_repeat_state(&state.repeat),
        }
    }
}

/// Redacted modal diagnostic shape.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VimModalStateShape {
    /// Current normal/insert/visual mode.
    mode: VimMode,
    /// Redacted visual selection state.
    selection: SelectionShape,
    /// Redacted search prompt state.
    search: SearchShape,
    /// Redacted command prompt state.
    command: PromptShape,
    /// Redacted leader state.
    leader: LeaderShape,
    /// Redacted status state.
    status: StatusShape,
    /// Redacted register state.
    registers: RegisterBankShape,
    /// Redacted dot-repeat state.
    repeat: RepeatStateShape,
}

impl VimModalStateShape {
    /// Builds a redacted diagnostic shape from modal state.
    #[must_use]
    pub fn from_modal_state(state: &VimModalState) -> Self {
        Self::from(state)
    }
}

impl From<&VimModalState> for VimModalStateShape {
    fn from(state: &VimModalState) -> Self {
        Self::from(&state.editor)
    }
}

impl From<&VimEditorState> for VimModalStateShape {
    fn from(state: &VimEditorState) -> Self {
        let shape = VimEditorStateShape::from(state);
        Self {
            mode: shape.mode,
            selection: shape.selection,
            search: shape.search,
            command: shape.command,
            leader: shape.leader,
            status: shape.status,
            registers: shape.registers,
            repeat: shape.repeat,
        }
    }
}

/// Redacted visual selection state.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SelectionShape {
    /// No visual selection anchor.
    Inactive,
    /// A visual selection anchor exists.
    Active,
}

impl SelectionShape {
    /// Builds a selection shape from an activity predicate.
    const fn from_active(active: bool) -> Self {
        if active { Self::Active } else { Self::Inactive }
    }
}

/// Redacted prompt state.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PromptShape {
    /// Prompt is not active.
    Inactive,
    /// Prompt is active.
    Active,
}

impl PromptShape {
    /// Builds a prompt shape from an activity predicate.
    const fn from_active(active: bool) -> Self {
        if active { Self::Active } else { Self::Inactive }
    }
}

/// Redacted search state.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct SearchShape {
    /// Active search prompt state.
    prompt: PromptShape,
    /// Previous search query presence.
    previous: PreviousSearchShape,
}

/// Redacted previous-search state.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PreviousSearchShape {
    /// No previous search query exists.
    Empty,
    /// A previous search query exists.
    Present,
}

impl PreviousSearchShape {
    /// Builds previous-search shape from a presence predicate.
    const fn from_present(present: bool) -> Self {
        if present { Self::Present } else { Self::Empty }
    }
}

/// Redacted leader state.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LeaderShape {
    /// Leader state is idle.
    Idle,
    /// Leader key has been pressed and is waiting for a binding.
    Pending,
    /// Leader menu is visible.
    MenuVisible,
}

impl LeaderShape {
    /// Builds leader shape from concrete leader state.
    const fn from_state(state: &LeaderState) -> Self {
        if state.is_menu_visible() {
            Self::MenuVisible
        } else if state.is_pending() {
            Self::Pending
        } else {
            Self::Idle
        }
    }
}

/// Redacted status state.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum StatusShape {
    /// No status message is present.
    Clear,
    /// A status message is present.
    Present,
}

impl StatusShape {
    /// Builds a status shape from a presence predicate.
    const fn from_present(present: bool) -> Self {
        if present { Self::Present } else { Self::Clear }
    }
}

#[cfg(test)]
mod tests {
    use super::VimModalState;
    use crate::text_stream::TextByteStream;
    use crate::vim::{OperatorTarget, RegisterName, RegisterText, SearchDirection};

    #[test]
    fn modal_state_debug_output_redacts_prompt_text() {
        let mut state = VimModalState::default();
        state.editor.command.start_with("secret-write-command");
        state.editor.search.start(SearchDirection::Forward);
        state.editor.search.push_text("secret-search-query");

        let debug = format!("{state:?}");

        assert!(debug.contains("VimModalState"));
        assert!(debug.contains("command"));
        assert!(debug.contains("search"));
        assert!(!debug.contains("secret-write-command"));
        assert!(!debug.contains("secret-search-query"));
    }

    #[test]
    fn modal_state_debug_output_redacts_register_text() {
        let text = "secret-register-text";
        let stream = TextByteStream::new(text);
        let target = OperatorTarget::characterwise(&stream, 0..text.len()).unwrap();
        let mut state = VimModalState::default();
        state.editor.registers.write_yank(&stream, target).unwrap();

        let debug = format!("{state:?}");

        assert!(debug.contains("registers"));
        assert!(debug.contains("byte_len"));
        assert!(!debug.contains("secret-register-text"));
    }

    #[test]
    fn modal_state_registers_are_view_local() {
        let first_text = "first-view-secret";
        let second_text = "second-view-secret";
        let first_stream = TextByteStream::new(first_text);
        let second_stream = TextByteStream::new(second_text);
        let first_target =
            OperatorTarget::characterwise(&first_stream, 0..first_text.len()).unwrap();
        let second_target =
            OperatorTarget::characterwise(&second_stream, 0..second_text.len()).unwrap();
        let mut first = VimModalState::default();
        let mut second = VimModalState::default();

        first
            .editor
            .registers
            .write_yank(&first_stream, first_target)
            .unwrap();
        second
            .editor
            .registers
            .write_yank(&second_stream, second_target)
            .unwrap();

        assert_eq!(
            first
                .editor
                .registers
                .get(RegisterName::Unnamed)
                .map(RegisterText::as_str),
            Some(first_text)
        );
        assert_eq!(
            second
                .editor
                .registers
                .get(RegisterName::Unnamed)
                .map(RegisterText::as_str),
            Some(second_text)
        );
    }
}