arf-console 0.3.4

A cross-platform R console written in Rust
//! Keyboard shortcut configuration.

use crate::editor::mode::{
    BufferKnownEmpty, ConditionalEditMode, ConditionalRule, CursorAtBegin, EditorStateRef,
    create_auto_match_rules, create_bracket_delete_rules, create_skip_over_rules,
};
use crokey::KeyCombination;
use reedline::{EditCommand, EditMode, KeyCode, KeyModifiers, Keybindings, ReedlineEvent};
use std::collections::BTreeMap;

/// Add common keybindings to an existing keybinding set.
///
/// Enter submits the input, Shift+Enter inserts a newline.
pub fn add_common_keybindings(keybindings: &mut Keybindings) {
    // Tab for completion
    keybindings.add_binding(
        KeyModifiers::NONE,
        KeyCode::Tab,
        ReedlineEvent::UntilFound(vec![
            ReedlineEvent::Menu("completion_menu".to_string()),
            ReedlineEvent::MenuNext,
        ]),
    );

    // ':' triggers completion menu for meta commands
    // This provides immediate feedback when entering meta command mode
    keybindings.add_binding(
        KeyModifiers::NONE,
        KeyCode::Char(':'),
        ReedlineEvent::Multiple(vec![
            ReedlineEvent::Edit(vec![EditCommand::InsertChar(':')]),
            ReedlineEvent::Menu("completion_menu".to_string()),
        ]),
    );

    // Ctrl+R for history search menu (shows multiple candidates)
    // First move cursor to end of buffer to ensure history selection replaces
    // the entire buffer. Without this, if cursor is in the middle (e.g., after
    // auto-match inserts a pair like ``), the selection would only replace text
    // up to the cursor, leaving trailing characters.
    keybindings.add_binding(
        KeyModifiers::CONTROL,
        KeyCode::Char('r'),
        ReedlineEvent::Multiple(vec![
            ReedlineEvent::Edit(vec![EditCommand::MoveToEnd { select: false }]),
            ReedlineEvent::UntilFound(vec![
                ReedlineEvent::Menu("history_menu".to_string()),
                ReedlineEvent::MenuPageNext,
            ]),
        ]),
    );

    // Enter submits, Shift+Enter inserts newline
    // Note: Enter submit is already the default behavior in reedline
    keybindings.add_binding(
        KeyModifiers::SHIFT,
        KeyCode::Enter,
        ReedlineEvent::Edit(vec![EditCommand::InsertNewline]),
    );
}

/// Wrap an EditMode with ConditionalEditMode for context-aware keybindings.
///
/// This applies several conditional rules:
/// - ':' only triggers completion menu when at line start (not in `1:10`)
/// - Auto-match only inserts closing brackets when cursor is at end of buffer
/// - Auto-trigger completion when buffer reaches `completion_min_chars` characters
/// - Tree-sitter based word navigation for Ctrl+Arrow (R token boundaries)
/// - When `shell_semicolon_shortcut` is true, ';' at empty buffer triggers shell mode
pub fn wrap_edit_mode_with_conditional_rules<E: EditMode + 'static>(
    edit_mode: E,
    state: EditorStateRef,
    auto_match: bool,
    completion_min_chars: Option<usize>,
    shell_semicolon_shortcut: bool,
) -> Box<dyn EditMode> {
    // Rule: when ':' produces InsertChar + Menu, check if cursor is at position 0
    // If not at position 0, replace with just InsertChar(':')
    let colon_rule = ConditionalRule {
        match_event: Box::new(|event| {
            // Match the exact event pattern from add_common_keybindings
            matches!(
                event,
                ReedlineEvent::Multiple(events)
                if events.len() == 2
                    && matches!(&events[0], ReedlineEvent::Edit(cmds) if cmds.len() == 1 && matches!(&cmds[0], EditCommand::InsertChar(':')))
                    && matches!(&events[1], ReedlineEvent::Menu(name) if name == "completion_menu")
            )
        }),
        condition: Box::new(CursorAtBegin),
        fallback_event: ReedlineEvent::Edit(vec![EditCommand::InsertChar(':')]),
    };

    let mut conditional = ConditionalEditMode::new(edit_mode, state)
        .with_rule(colon_rule)
        .with_completion_min_chars(completion_min_chars)
        .with_tree_sitter_word_nav(true);

    // Add smart auto-match rules if enabled
    if auto_match {
        // Bracket delete rules must come first - they handle backspace in empty pairs
        conditional = conditional.with_rules(create_bracket_delete_rules());
        // Skip-over rules must come before auto-match - when cursor is before a closing
        // character, just move right instead of inserting another character
        conditional = conditional.with_rules(create_skip_over_rules());
        // Auto-match rules handle inserting closing brackets
        conditional = conditional.with_rules(create_auto_match_rules());
    }

    // When the semicolon shortcut is enabled, ';' at an empty buffer inserts
    // ":shell" and submits immediately. When the buffer has content, fall back
    // to inserting a literal semicolon so normal R expressions are unaffected.
    if shell_semicolon_shortcut {
        let semicolon_rule = ConditionalRule {
            match_event: Box::new(|event| {
                matches!(
                    event,
                    ReedlineEvent::Multiple(events)
                    if events.len() == 2
                        && matches!(
                            &events[0],
                            ReedlineEvent::Edit(cmds)
                            if cmds.len() == 1
                                && matches!(&cmds[0], EditCommand::InsertString(s) if s == ":shell")
                        )
                        && matches!(&events[1], ReedlineEvent::Submit)
                )
            }),
            condition: Box::new(BufferKnownEmpty),
            fallback_event: ReedlineEvent::Edit(vec![EditCommand::InsertChar(';')]),
        };
        conditional = conditional.with_rule(semicolon_rule);
    }

    Box::new(conditional)
}

/// Add the `;` → shell-mode keybinding.
///
/// Maps `;` to `Multiple([InsertString(":shell"), Submit])` so that pressing
/// `;` at an empty prompt inserts `:shell` and submits it immediately — no
/// Enter required. A `ConditionalRule` added by
/// `wrap_edit_mode_with_conditional_rules` falls back to `InsertChar(';')`
/// when the buffer is not empty, preserving normal semicolon input.
pub fn add_shell_semicolon_keybinding(keybindings: &mut Keybindings) {
    let event = ReedlineEvent::Multiple(vec![
        ReedlineEvent::Edit(vec![EditCommand::InsertString(":shell".to_string())]),
        ReedlineEvent::Submit,
    ]);
    // Bind both NONE and SHIFT: on some platforms/layouts crossterm includes
    // SHIFT in the key event even when it is part of typing the character
    // (same rationale as add_auto_match_keybindings).
    keybindings.add_binding(KeyModifiers::NONE, KeyCode::Char(';'), event.clone());
    keybindings.add_binding(KeyModifiers::SHIFT, KeyCode::Char(';'), event);
}

/// Add auto-match keybindings for brackets and quotes.
///
/// When typing an opening bracket or quote, automatically inserts the closing
/// counterpart and positions the cursor between them.
///
/// Also binds closing brackets/quotes to `InsertChar` so that skip-over rules
/// can distinguish between opening and closing character key presses.
pub fn add_auto_match_keybindings(keybindings: &mut Keybindings) {
    // Define pairs: (opening char, closing char, pair string)
    let pairs = [
        ('(', ')', "()"),
        ('[', ']', "[]"),
        ('{', '}', "{}"),
        ('"', '"', r#""""#),
        ('\'', '\'', "''"),
        ('`', '`', "``"),
    ];

    for (open_char, close_char, pair) in pairs {
        // Opening char: insert pair and move cursor between them
        let open_event = ReedlineEvent::Edit(vec![
            EditCommand::InsertString(pair.to_string()),
            EditCommand::MoveLeft { select: false },
        ]);

        // Bind both NONE and SHIFT modifiers for all characters.
        // On Windows, crossterm may include SHIFT in the key event even when
        // Shift is "part of" typing the character (e.g., Shift+9 for '(').
        // Different keyboard layouts have different shift requirements:
        // - US: '(' = Shift+9, '{' = Shift+[
        // - French AZERTY: '(' = 5 (no shift), '{' = AltGr+4
        // By binding both variants, we handle all common layouts.
        keybindings.add_binding(
            KeyModifiers::NONE,
            KeyCode::Char(open_char),
            open_event.clone(),
        );
        keybindings.add_binding(KeyModifiers::SHIFT, KeyCode::Char(open_char), open_event);

        // Closing char: just insert the character (skip-over rules will handle
        // moving right when cursor is before the same closing char).
        // Only bind for non-quote pairs (quotes use same char for open/close).
        if open_char != close_char {
            let close_event = ReedlineEvent::Edit(vec![EditCommand::InsertChar(close_char)]);
            keybindings.add_binding(
                KeyModifiers::NONE,
                KeyCode::Char(close_char),
                close_event.clone(),
            );
            keybindings.add_binding(KeyModifiers::SHIFT, KeyCode::Char(close_char), close_event);
        }
    }
}

/// Add keybindings for inserting text (like assignment and pipe operators).
///
/// Configurable via `editor.key_map` in config using crokey format.
/// Example: "alt-hyphen" = " <- ", "alt-p" = " |> "
pub fn add_key_map_keybindings(
    keybindings: &mut Keybindings,
    key_map: &BTreeMap<KeyCombination, String>,
) {
    use crossterm::event::KeyEvent;

    for (key_combination, text) in key_map {
        // Convert crokey::KeyCombination to crossterm KeyEvent
        let key_event: KeyEvent = (*key_combination).into();
        keybindings.add_binding(
            key_event.modifiers,
            key_event.code,
            ReedlineEvent::Edit(vec![EditCommand::InsertString(text.clone())]),
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use reedline::{EditCommand, KeyCode, KeyModifiers, ReedlineEvent};

    fn shell_event() -> ReedlineEvent {
        ReedlineEvent::Multiple(vec![
            ReedlineEvent::Edit(vec![EditCommand::InsertString(":shell".to_string())]),
            ReedlineEvent::Submit,
        ])
    }

    #[test]
    fn test_shell_semicolon_keybinding_none_modifier() {
        let mut kb = reedline::Keybindings::new();
        add_shell_semicolon_keybinding(&mut kb);
        assert_eq!(
            kb.find_binding(KeyModifiers::NONE, KeyCode::Char(';')),
            Some(shell_event()),
            "NONE+';' should map to the shell shortcut event"
        );
    }

    #[test]
    fn test_shell_semicolon_keybinding_shift_modifier() {
        let mut kb = reedline::Keybindings::new();
        add_shell_semicolon_keybinding(&mut kb);
        assert_eq!(
            kb.find_binding(KeyModifiers::SHIFT, KeyCode::Char(';')),
            Some(shell_event()),
            "SHIFT+';' should map to the same shell shortcut event for cross-layout support"
        );
    }
}