hjkl-vim 0.44.0

Vim modal state types and grammar primitives for the hjkl editor stack. Pre-1.0 churn.
Documentation
use crate::editor_ext::VimEditorExt;
/// Phase 6.6c: `step_search_prompt` relocated from `hjkl-engine::vim`.
///
/// This module owns the search-prompt FSM arm. It is dispatched by
/// [`crate::dispatch_input`] before `begin_step`, since it needs no
/// prelude/epilogue.
use hjkl_engine::{Host, Input, Key};

/// Drive the search-prompt FSM for one keystroke.
///
/// Returns `true` (consumed) unconditionally — every key inside the prompt
/// is swallowed regardless of whether it produced a visible effect.
pub fn step_search_prompt<H: Host>(
    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
    input: Input,
) -> bool {
    // In a macro vim's `<Esc>` runs the command line instead of quitting it
    // (`:h c_<Esc>`), so a replayed `/pat<Esc>` searches.
    let input = if input.key == Key::Esc && crate::vim_state::vim(ed).replaying_macro {
        Input {
            key: Key::Enter,
            ..input
        }
    } else {
        input
    };
    // Ctrl-P / Ctrl-N (and Up / Down) walk the search history. Handled
    // before the regular char/backspace branches so `Ctrl-P` doesn't
    // type a literal `p`.
    let history_dir = match (input.key, input.ctrl) {
        (Key::Char('p'), true) | (Key::Up, _) => Some(-1isize),
        (Key::Char('n'), true) | (Key::Down, _) => Some(1isize),
        _ => None,
    };
    if let Some(dir) = history_dir {
        ed.walk_search_history(dir);
        return true;
    }
    match input.key {
        Key::Esc => {
            // Cancel. Drop the prompt but keep the highlighted matches
            // so `n` / `N` can repeat whatever was typed.
            let text = ed
                .take_search_prompt_state()
                .map(|p| p.text)
                .unwrap_or_default();
            if !text.is_empty() {
                ed.set_last_search_pattern_only(Some(text));
            }
            ed.set_search_history_cursor(None);
        }
        Key::Enter => {
            let prompt = ed.take_search_prompt_state();
            if let Some(p) = prompt {
                // Split a trailing search offset (`/pat/e`, `/pat/+1`,
                // `/pat/s-2`, ...) from the pattern. The delimiter is whatever
                // opened the prompt (`/` forward, `?` backward).
                let delim = if p.forward { '/' } else { '?' };
                let (pat_text, offset) = split_search_offset(&p.text, delim);
                // Empty `/<CR>` (or `?<CR>`) re-runs the previous search
                // pattern in the prompt's direction, with its offset unless
                // one is given (`//e`; an empty `//` drops it) — vim parity.
                let (pattern, offset) = if pat_text.is_empty() {
                    let offset = match offset {
                        Some(off) => Some(off),
                        None => ed.last_search_offset(),
                    };
                    (ed.last_search_pattern(), offset)
                } else {
                    (Some(pat_text), offset)
                };
                if let Some(pattern) = pattern {
                    ed.push_search_pattern(&pattern);
                    let pre = ed.cursor();
                    let offset = offset.filter(|s| !s.is_empty());
                    let char_offset = offset.as_deref().and_then(parse_char_offset);
                    let found = search_with_offset(ed, p.forward, offset.as_deref(), 1);
                    // Stored before the operator runs: `apply_op_search_range`
                    // reads it for a line offset's linewise range.
                    ed.set_last_search_offset(offset.clone());
                    // Operator-pending search (`d/pat`, `c/pat`, `y/pat`): apply
                    // the operator over the range to the match instead of just
                    // moving the cursor / pushing a jump.
                    if let Some((op, _count, origin)) = p.operator {
                        // An `e` offset makes the motion inclusive (`:h
                        // search-offset`): end the exclusive range one past it.
                        if found && matches!(char_offset, Some((true, _))) {
                            let (row, col) = ed.cursor();
                            hjkl_engine::buf_helpers::buf_set_cursor_rc(
                                ed.buffer_mut(),
                                row,
                                col + 1,
                            );
                        }
                        ed.apply_op_search_range(op, origin);
                    } else {
                        if ed.cursor() != pre {
                            ed.push_jump(pre);
                        }
                        // `'foldopen'` `search`.
                        if found {
                            crate::vim::foldopen_cursor(ed);
                        }
                    }
                    ed.record_search_history(&pattern);
                    ed.set_last_search_pattern_only(Some(pattern));
                    ed.set_last_search_forward_only(p.forward);
                }
            }
            ed.set_search_history_cursor(None);
        }
        Key::Backspace => {
            ed.set_search_history_cursor(None);
            let new_text = ed.search_prompt_state_mut().and_then(|p| {
                if p.text.pop().is_some() {
                    p.cursor = p.text.chars().count();
                    Some(p.text.clone())
                } else {
                    None
                }
            });
            if let Some(text) = new_text {
                ed.push_search_pattern(&text);
            }
        }
        Key::Char(c) => {
            ed.set_search_history_cursor(None);
            let new_text = ed.search_prompt_state_mut().map(|p| {
                p.text.push(c);
                p.cursor = p.text.chars().count();
                p.text.clone()
            });
            if let Some(text) = new_text {
                ed.push_search_pattern(&text);
            }
        }
        _ => {}
    }
    true
}

/// Find the `count`th match of the installed search pattern in direction
/// `forward` and land on it with `offset` applied, the way vim's `do_search`
/// does for `/pat/{offset}` and for `n` / `N` repeating it. An `e` / `s` / `b`
/// offset shifts where the first search starts (see [`offset_search_match`]);
/// the offset itself is applied once, after the last match. Returns whether a
/// match was found.
pub(crate) fn search_with_offset<H: Host>(
    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
    forward: bool,
    offset: Option<&str>,
    count: usize,
) -> bool {
    let mut found = false;
    for i in 0..count.max(1) {
        let char_offset = offset.and_then(parse_char_offset).filter(|_| i == 0);
        found = match char_offset {
            Some((end, n)) => match offset_search_match(ed, forward, end, n) {
                Some((row, col)) => {
                    ed.jump_cursor(row, col);
                    true
                }
                None => false,
            },
            None if forward => ed.search_advance_forward(true),
            None => ed.search_advance_backward(true),
        };
        if !found {
            return false;
        }
    }
    if let Some(off) = offset {
        apply_search_offset(ed, off);
    }
    found
}

/// Split a search string into `(pattern, offset)` on the first UNESCAPED
/// delimiter (`/` for forward search, `?` for backward). Returns `offset =
/// None` when no delimiter is present (a bare pattern).
fn split_search_offset(text: &str, delim: char) -> (String, Option<String>) {
    let chars: Vec<char> = text.chars().collect();
    let mut i = 0;
    while i < chars.len() {
        if chars[i] == '\\' {
            i += 2;
            continue;
        }
        if chars[i] == delim {
            let pat: String = chars[..i].iter().collect();
            let off: String = chars[i + 1..].iter().collect();
            return (pat, Some(off));
        }
        i += 1;
    }
    (text.to_string(), None)
}

/// Apply a vim search offset to the cursor, which the search just left at the
/// match start. Supports:
/// - `e[+-N]` — end of match, ± N chars
/// - `s[+-N]` / `b[+-N]` — start of match, ± N chars
/// - `[+-]N` / `N` — line offset (N lines down/up, first non-blank)
fn apply_search_offset<H: Host>(ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>, offset: &str) {
    let (row, col) = ed.cursor();
    match offset.chars().next() {
        Some('e') => {
            // Saturating arithmetic: `n` is user input and can be `isize::MAX`
            // / `isize::MIN`, which would overflow a plain add (panic in
            // debug builds).
            let n: isize = offset[1..].parse().unwrap_or(0);
            let len = match_len_at(ed, row, col) as isize;
            let end = (col as isize)
                .saturating_add((len - 1).max(0))
                .saturating_add(n);
            ed.jump_cursor(row, end.max(0) as usize);
        }
        Some('s') | Some('b') => {
            let n: isize = offset[1..].parse().unwrap_or(0);
            ed.jump_cursor(row, (col as isize).saturating_add(n).max(0) as usize);
        }
        _ => {
            // Line offset: move N lines and land on the first non-blank.
            let n: isize = offset.parse().unwrap_or(0);
            let rope = ed.buffer().rope();
            let last = rope.len_lines().saturating_sub(1);
            let new_row = (row as isize).saturating_add(n).clamp(0, last as isize) as usize;
            let line = hjkl_buffer::rope_line_str(&rope, new_row);
            let fnb = line.chars().take_while(|c| *c == ' ' || *c == '\t').count();
            drop(rope);
            ed.jump_cursor(new_row, fnb);
        }
    }
}

/// An `e` / `s` / `b` search offset as `(from_end, n)`; `None` for a line
/// offset (`+2`, `-1`, `3`).
fn parse_char_offset(offset: &str) -> Option<(bool, isize)> {
    let end = match offset.chars().next()? {
        'e' => true,
        's' | 'b' => false,
        _ => return None,
    };
    Some((end, offset[1..].parse().unwrap_or(0)))
}

/// vim's `do_search` for a search with an `e` / `s` / `b` offset of `n`: the
/// search starts `n` characters before the cursor (after it, for a negative
/// `n`; before the buffer or after it when that runs off an end), and an `e`
/// offset compares each match's last character instead of its first. So the
/// match under the cursor can be the one found, as for `/ab/e` from the `a` of
/// `"ab"`. Returns the start of the match to land on, wrapping around the
/// buffer under `'wrapscan'`.
fn offset_search_match<H: Host>(
    ed: &hjkl_engine::Editor<hjkl_buffer::View, H>,
    forward: bool,
    from_end: bool,
    n: isize,
) -> Option<(usize, usize)> {
    let re = ed.search_state().pattern.as_ref()?;
    let rope = ed.buffer().rope();
    let rows = rope.len_lines();
    let lines: Vec<String> = (0..rows)
        .map(|r| hjkl_buffer::rope_line_str(&rope, r))
        .collect();
    // Each match as (key, start), the key being the position `searchit`
    // compares: its first character, or its last for an `e` offset.
    let mut matches: Vec<((isize, isize), (usize, usize))> = Vec::new();
    for (row, line) in lines.iter().enumerate() {
        let char_at = |byte: usize| line[..byte].chars().count();
        for (start, end) in hjkl_buffer::search_match_ranges(re, line) {
            let first = char_at(start);
            let last = if end > start { char_at(end) - 1 } else { first };
            let key = if from_end { last } else { first };
            matches.push(((row as isize, key as isize), (row, first)));
        }
    }
    // vim's `decl` / `incl`: one character back / forward, across lines,
    // skipping the end of a non-empty line.
    let cells = |r: isize| lines[r as usize].chars().count().max(1) as isize;
    let step = |(r, c): (isize, isize), back: bool| -> Option<(isize, isize)> {
        if back {
            if c > 0 {
                Some((r, c - 1))
            } else if r > 0 {
                Some((r - 1, cells(r - 1) - 1))
            } else {
                None
            }
        } else if c + 1 < cells(r) {
            Some((r, c + 1))
        } else if r + 1 < rows as isize {
            Some((r + 1, 0))
        } else {
            None
        }
    };
    let (row, col) = ed.cursor();
    let mut from = (row as isize, col as isize);
    let total: usize = (0..rows).map(|r| cells(r as isize) as usize).sum();
    for _ in 0..n.unsigned_abs().min(total) {
        match step(from, n > 0) {
            Some(p) => from = p,
            None if n > 0 => {
                from = (-1, isize::MAX);
                break;
            }
            None => {
                from = (rows as isize, 0);
                break;
            }
        }
    }
    let wrap = ed.settings().wrapscan;
    let found = if forward {
        matches
            .iter()
            .find(|(key, _)| *key > from)
            .or_else(|| wrap.then(|| matches.first()).flatten())
    } else {
        matches
            .iter()
            .rev()
            .find(|(key, _)| *key < from)
            .or_else(|| wrap.then(|| matches.last()).flatten())
    };
    found.map(|&(_, start)| start)
}

/// Char-length of the regex match that begins at `(row, col)`. Falls back to 1
/// when no pattern is set or no match starts there.
fn match_len_at<H: Host>(
    ed: &hjkl_engine::Editor<hjkl_buffer::View, H>,
    row: usize,
    col: usize,
) -> usize {
    let Some(re) = ed.search_state().pattern.as_ref() else {
        return 1;
    };
    let line = hjkl_buffer::rope_line_str(&ed.buffer().rope(), row);
    let byte_col = line.char_indices().nth(col).map_or(line.len(), |(b, _)| b);
    if let Some(m) = re.find(&line[byte_col..])
        && m.start() == 0
    {
        return line[byte_col..byte_col + m.end()].chars().count();
    }
    1
}

#[cfg(test)]
mod offset_tests {
    use super::split_search_offset;

    #[test]
    fn plain_pattern_no_offset() {
        assert_eq!(split_search_offset("bar", '/'), ("bar".into(), None));
    }
    #[test]
    fn word_boundary_pattern_preserved() {
        assert_eq!(
            split_search_offset("\\<bar\\>", '/'),
            ("\\<bar\\>".into(), None)
        );
    }
    #[test]
    fn trailing_delim_empty_offset() {
        assert_eq!(
            split_search_offset("bar/", '/'),
            ("bar".into(), Some(String::new()))
        );
    }
    #[test]
    fn end_offset() {
        assert_eq!(
            split_search_offset("bar/e", '/'),
            ("bar".into(), Some("e".into()))
        );
    }
    #[test]
    fn escaped_delim_not_split() {
        assert_eq!(split_search_offset("a\\/b", '/'), ("a\\/b".into(), None));
    }
}