hjkl-vim 0.40.0

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

use hjkl_engine::search::SearchPrompt;
use hjkl_vim_types::{Operator, RangeKind};

use super::*;
use crate::vim_state::{vim, vim_mut};
use hjkl_engine::Editor;
use hjkl_engine::buf_helpers::{buf_cursor_pos, buf_set_cursor_rc};
use hjkl_engine::tag::{is_html_filetype, scan_tag_opener};

/// Open the `/` (forward) or `?` (backward) search prompt. Clears any
/// live search highlight until the user commits a query. `last_search`
/// is preserved so an empty `<CR>` can re-run the previous pattern.
pub fn enter_search<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    forward: bool,
) {
    ed.set_search_prompt_state(Some(SearchPrompt {
        text: String::new(),
        cursor: 0,
        forward,
        operator: None,
    }));
    ed.set_search_history_cursor(None);
    // 0.0.37: clear via the engine search state (the buffer-side
    // bridge from 0.0.35 was removed in this patch — the `BufferView`
    // renderer reads the pattern from `Editor::search_state()`).
    ed.set_search_pattern(None);
}
/// `d/pat` / `c/pat` / `y/pat` (and `?` forms) — open the search prompt in
/// operator-pending mode. On commit the operator runs over the exclusive
/// charwise range from the current cursor to the match.
pub fn enter_search_op<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    forward: bool,
    op: Operator,
    count: usize,
) {
    let origin = ed.cursor();
    ed.set_search_prompt_state(Some(SearchPrompt {
        text: String::new(),
        cursor: 0,
        forward,
        operator: Some((op, count.max(1), origin)),
    }));
    ed.set_search_history_cursor(None);
    ed.set_search_pattern(None);
}
/// Apply a pending operator-search over the exclusive charwise range from
/// `origin` to the current (post-search) cursor. Used by the search-prompt
/// commit path for `d/` / `c/` / `y/`.
pub fn apply_op_search_range<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    op: Operator,
    origin: (usize, usize),
) {
    let target = ed.cursor();
    run_operator_over_range(ed, op, origin, target, RangeKind::Exclusive);
}
/// `g;` / `g,` body. `dir = -1` walks toward older entries (g;),
/// `dir = 1` toward newer (g,). `count` repeats the step. Stops at
/// the ends of the ring; off-ring positions are silently ignored.
pub fn walk_change_list<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    dir: isize,
    count: usize,
) {
    let (list, _) = ed.change_list();
    if list.is_empty() {
        return;
    }
    let len = list.len();
    let mut idx: isize = match (ed.change_list_cursor(), dir) {
        (None, -1) => len as isize - 1,
        (None, 1) => return, // already past the newest entry
        (Some(i), -1) => i as isize - 1,
        (Some(i), 1) => i as isize + 1,
        _ => return,
    };
    for _ in 1..count {
        let next = idx + dir;
        if next < 0 || next >= len as isize {
            break;
        }
        idx = next;
    }
    if idx < 0 || idx >= len as isize {
        return;
    }
    let idx = idx as usize;
    ed.set_change_list_cursor(Some(idx));
    let (row, col) = list[idx];
    ed.jump_cursor(row, col);
}
/// Refresh the `"+`/`"*` clipboard register slot from the host's live OS
/// clipboard right before a read that's about to consume it (`"+p`/`"*p`,
/// `<C-r>+`, visual-mode paste). No-op for any other selector.
///
/// `Editor::sync_clipboard_register` (the engine's OS-clipboard import
/// hook) had zero callers before this — the `"+`/`"*` slot only ever
/// reflected the last in-editor `"+y`, so pasting something copied
/// outside hjkl (a browser, another app) pasted stale or empty text.
///
/// `host.read_clipboard()` returning `None` (headless/CI/PTY hosts with
/// no OS clipboard bridge, or OSC52 being unreadable over a plain
/// terminal) falls back to leaving the existing slot untouched — mirrors
/// how the paste path already falls back to whatever's cached rather than
/// erroring on a clipboard miss.
///
/// Linewise heuristic matches `"+y`'s own writer: text ending in `\n` is
/// linewise, matching vim's "did the yank end with a newline" rule.
pub fn sync_clipboard_register_for<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    selector: Option<char>,
) {
    if !matches!(selector, Some('+') | Some('*')) {
        return;
    }
    if let Some(text) = ed.host_mut().read_clipboard() {
        let linewise = text.ends_with('\n');
        ed.sync_clipboard_register(text, linewise);
    }
}

/// `Ctrl-R {reg}` body — insert the named register's contents at the
/// cursor as charwise text. Embedded newlines split lines naturally via
/// `Edit::InsertStr`. Unknown selectors and empty slots are no-ops so
/// stray keystrokes don't mutate the buffer.
pub fn insert_register_text<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    selector: char,
) {
    use hjkl_buffer::Edit;
    sync_clipboard_register_for(ed, Some(selector));
    // Special read-only registers: `/` = last search pattern, `.` = last
    // inserted text. Fall back to the register store for everything else.
    let text = match selector {
        '/' => match &ed.last_search_pattern() {
            Some(s) if !s.is_empty() => s.clone(),
            _ => return,
        },
        '.' => match &vim(ed).last_insert_text {
            Some(s) if !s.is_empty() => s.clone(),
            _ => return,
        },
        _ => match ed.with_registers(|r| r.read(selector).cloned()) {
            Some(slot) if !slot.text.is_empty() => slot.text,
            _ => return,
        },
    };
    ed.sync_buffer_content_from_textarea();
    let cursor = buf_cursor_pos(ed.buffer());
    ed.mutate_edit(Edit::InsertStr {
        at: cursor,
        text: text.clone(),
    });
    // Advance cursor to the end of the inserted payload — multi-line
    // pastes land on the last inserted row at the post-text column.
    let mut row = cursor.row;
    let mut col = cursor.col;
    for ch in text.chars() {
        if ch == '\n' {
            row += 1;
            col = 0;
        } else {
            col += 1;
        }
    }
    buf_set_cursor_rc(ed.buffer_mut(), row, col);
    ed.mark_content_dirty();
    if let Some(ref mut session) = vim_mut(ed).insert_session {
        session.row_min = session.row_min.min(row);
        session.row_max = session.row_max.max(row);
    }
}
/// Compute the indent string to insert at the start of a new line
/// after Enter is pressed at `cursor`. Walks the smartindent rules:
///
/// - autoindent off → empty string
/// - autoindent on  → copy prev line's leading whitespace
/// - smartindent on → bump one `shiftwidth` if prev line's last
///   non-whitespace char is `{` / `(` / `[`
///
/// Indent unit (used for the smartindent bump):
///
/// - `expandtab && softtabstop > 0` → `softtabstop` spaces
/// - `expandtab` → `shiftwidth` spaces
/// - `!expandtab` → one literal `\t`
///
/// This is the placeholder for a future tree-sitter indent provider:
/// when a language has an `indents.scm` query, the engine will route
/// the same call through that provider and only fall back to this
/// heuristic when no query matches.
pub(super) fn compute_enter_indent(settings: &hjkl_engine::Settings, prev_line: &str) -> String {
    if !settings.autoindent {
        return String::new();
    }
    // Copy the prev line's leading whitespace (autoindent base).
    let base: String = prev_line
        .chars()
        .take_while(|c| *c == ' ' || *c == '\t')
        .collect();

    if settings.smartindent {
        let unit = if settings.expandtab {
            if settings.softtabstop > 0 {
                " ".repeat(settings.softtabstop)
            } else {
                " ".repeat(settings.shiftwidth)
            }
        } else {
            "\t".to_string()
        };

        // Open-bracket bump — language-agnostic.
        let last_non_ws = prev_line.chars().rev().find(|c| !c.is_whitespace());
        if matches!(last_non_ws, Some('{' | '(' | '[')) {
            return format!("{base}{unit}");
        }

        // HTML-family opening-tag bump: `<head>` / `<div class="...">`.
        // Gated on filetype so Rust generics like `Vec<T>` don't trigger.
        // Reuses scan_tag_opener which already filters self-closing and
        // void elements.
        if is_html_filetype(&settings.filetype) {
            let trimmed_end_len = prev_line
                .trim_end_matches(|c: char| c.is_whitespace())
                .len();
            let trimmed = &prev_line[..trimmed_end_len];
            if let Some(stripped) = trimmed.strip_suffix('>')
                && scan_tag_opener(trimmed, stripped.len()).is_some()
            {
                return format!("{base}{unit}");
            }
        }
    }

    base
}