use crate::editor_ext::VimEditorExt;
use hjkl_engine::{Host, Input, Key};
pub fn step_search_prompt<H: Host>(
ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
input: Input,
) -> bool {
let input = if input.key == Key::Esc && crate::vim_state::vim(ed).replaying_macro {
Input {
key: Key::Enter,
..input
}
} else {
input
};
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 => {
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 {
let delim = if p.forward { '/' } else { '?' };
let (pat_text, offset) = split_search_offset(&p.text, delim);
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);
ed.set_last_search_offset(offset.clone());
if let Some((op, _count, origin)) = p.operator {
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);
}
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
}
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
}
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)
}
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') => {
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);
}
_ => {
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);
}
}
}
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)))
}
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();
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)));
}
}
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)
}
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));
}
}