alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Vim command-line state and parsing.

use bevy::prelude::Resource;

use super::error::VimError;

/// Maximum accepted `:` command length in Unicode scalar values.
const MAX_COMMAND_CHARS: usize = 256;

/// Command-line state for Vim `:` commands.
#[derive(Clone, Debug, Default, Eq, PartialEq, Resource)]
pub struct VimCommandState {
    /// Command text currently being edited after `:`.
    active_command: Option<VimCommandText>,
}

impl VimCommandState {
    /// Starts editing a `:` command.
    pub fn start(&mut self) {
        self.active_command = Some(VimCommandText::new());
    }

    /// Starts editing a `:` command prefilled with text.
    pub fn start_with(&mut self, text: &str) {
        let mut command = VimCommandText::new();
        command.push_str(text);
        self.active_command = Some(command);
    }

    /// Cancels active command editing.
    pub fn cancel(&mut self) {
        self.active_command = None;
    }

    /// Returns whether a command is being edited.
    #[must_use]
    pub const fn is_active(&self) -> bool {
        self.active_command.is_some()
    }

    /// Reads the active command, if any.
    #[must_use]
    pub const fn active_command(&self) -> Option<&VimCommandText> {
        self.active_command.as_ref()
    }

    /// Appends text to the active command.
    pub fn push_text(&mut self, text: &str) {
        if let Some(command) = &mut self.active_command {
            command.push_str(text);
        }
    }

    /// Deletes the previous scalar from the active command.
    pub fn backspace(&mut self) {
        if let Some(command) = &mut self.active_command {
            command.pop_char();
        }
    }

    /// Submits the active command for parsing.
    ///
    /// # Errors
    ///
    /// Returns [`VimError::NotAnEditorCommand`] when no command is active or when the command text
    /// is not one of the supported editor commands.
    pub fn submit(&mut self) -> Result<VimCommand, VimError> {
        let Some(command_text) = self.active_command.take() else {
            return Err(VimError::NotAnEditorCommand);
        };

        command_text.parse()
    }
}

/// User-provided text following a Vim `:` prompt.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct VimCommandText {
    /// Raw command text, without the leading colon.
    text: String,
}

impl VimCommandText {
    /// Creates an empty command text value.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            text: String::new(),
        }
    }

    /// Reads the command text.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.text
    }

    /// Appends text, truncating at the configured scalar limit.
    pub fn push_str(&mut self, text: &str) {
        let remaining = MAX_COMMAND_CHARS.saturating_sub(self.text.chars().count());
        self.text.extend(text.chars().take(remaining));
    }

    /// Removes the previous Unicode scalar value.
    pub fn pop_char(&mut self) {
        let _removed = self.text.pop();
    }

    /// Parses this command text as a supported Vim editor command.
    fn parse(self) -> Result<VimCommand, VimError> {
        parse_command(self.text.trim())
    }
}

/// Supported Vim editor commands.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VimCommand {
    /// `:write` / `:w`.
    Write,
    /// `:quit` / `:q`.
    Quit(QuitPolicy),
    /// Write the file and quit.
    WriteQuit(WriteQuitPolicy),
}

/// Quit behavior for commands that may discard changes.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum QuitPolicy {
    /// Refuse to quit when the buffer is modified.
    PreserveChanges,
    /// Quit even when the buffer is modified.
    Force,
}

/// Write behavior for commands that write and quit.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WriteQuitPolicy {
    /// Always write before quitting, as `:wq` does.
    AlwaysWrite,
    /// Write only when the buffer is modified, as `:x` does.
    WriteIfModified,
}

/// Parses supported Vim command names.
fn parse_command(text: &str) -> Result<VimCommand, VimError> {
    match text {
        "w" | "write" => Ok(VimCommand::Write),
        "q" | "quit" => Ok(VimCommand::Quit(QuitPolicy::PreserveChanges)),
        "q!" | "quit!" => Ok(VimCommand::Quit(QuitPolicy::Force)),
        "wq" | "wq!" => Ok(VimCommand::WriteQuit(WriteQuitPolicy::AlwaysWrite)),
        "x" | "xit" | "exit" => Ok(VimCommand::WriteQuit(WriteQuitPolicy::WriteIfModified)),
        _ => Err(VimError::NotAnEditorCommand),
    }
}

#[cfg(test)]
mod tests {
    use super::{QuitPolicy, VimCommand, VimCommandState, VimCommandText, WriteQuitPolicy};
    use crate::vim::VimError;

    #[test]
    fn parses_common_write_and_quit_commands() {
        assert_eq!(
            VimCommandText::new().parse(),
            Err(VimError::NotAnEditorCommand)
        );

        let mut command = VimCommandText::new();
        command.push_str("wq");
        assert_eq!(
            command.parse(),
            Ok(VimCommand::WriteQuit(WriteQuitPolicy::AlwaysWrite))
        );

        let mut command = VimCommandText::new();
        command.push_str("q!");
        assert_eq!(command.parse(), Ok(VimCommand::Quit(QuitPolicy::Force)));
    }

    #[test]
    fn command_state_submits_and_clears_active_command() {
        let mut state = VimCommandState::default();

        state.start();
        state.push_text("write");

        assert_eq!(state.submit(), Ok(VimCommand::Write));
        assert!(!state.is_active());
    }
}