use regex::Regex;
use crate::types::{Cursor, Query, Search};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaseMode {
Sensitive,
Insensitive,
Smart,
}
impl CaseMode {
pub fn from_options(ignorecase: bool, smartcase: bool) -> Self {
if !ignorecase {
CaseMode::Sensitive
} else if smartcase {
CaseMode::Smart
} else {
CaseMode::Insensitive
}
}
}
pub fn resolve_case_mode(pat: &str, base: CaseMode) -> (String, CaseMode) {
let mut out = String::with_capacity(pat.len());
let mut chars = pat.chars().peekable();
let mut override_mode: Option<bool> = None;
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.peek() {
Some('c') => {
chars.next();
override_mode = Some(true); }
Some('C') => {
chars.next();
override_mode = Some(false); }
Some('<') => {
chars.next();
out.push_str(r"\b");
}
Some('>') => {
chars.next();
out.push_str(r"\b");
}
_ => {
out.push('\\');
if let Some(next) = chars.next() {
out.push(next);
}
}
}
} else {
out.push(ch);
}
}
let resolved = match override_mode {
Some(true) => CaseMode::Insensitive,
Some(false) => CaseMode::Sensitive,
None => match base {
CaseMode::Smart => {
if out.chars().any(|c| c.is_uppercase()) {
CaseMode::Sensitive
} else {
CaseMode::Insensitive
}
}
other => other,
},
};
(out, resolved)
}
pub fn vim_to_rust_regex(pat: &str) -> String {
resolve_case_mode(pat, CaseMode::Sensitive).0
}
#[derive(Debug, Clone, Default)]
pub struct SearchState {
pub pattern: Option<Regex>,
pub forward: bool,
pub matches: Vec<Vec<(usize, usize)>>,
pub generations: Vec<u64>,
pub wrap_around: bool,
}
impl SearchState {
pub fn new() -> Self {
Self {
pattern: None,
forward: true,
matches: Vec::new(),
generations: Vec::new(),
wrap_around: true,
}
}
pub fn set_pattern(&mut self, re: Option<Regex>) {
self.pattern = re;
self.matches.clear();
self.generations.clear();
}
pub fn matches_for(&mut self, row: usize, line: &str, dirty_gen: u64) -> &[(usize, usize)] {
let Some(ref re) = self.pattern else {
return &[];
};
if self.matches.len() <= row {
self.matches.resize_with(row + 1, Vec::new);
self.generations.resize(row + 1, u64::MAX);
}
if self.generations[row] != dirty_gen {
self.matches[row] = re.find_iter(line).map(|m| (m.start(), m.end())).collect();
self.generations[row] = dirty_gen;
}
&self.matches[row]
}
}
pub fn search_forward<B: Cursor + Query + Search>(
buf: &mut B,
state: &mut SearchState,
skip_current: bool,
) -> bool {
let Some(re) = state.pattern.clone() else {
return false;
};
let cursor = buf.cursor();
let total = buf.line_count();
if total == 0 {
return false;
}
let from = if skip_current {
let from_byte = buf.byte_offset(cursor);
buf.pos_at_byte(from_byte.saturating_add(1))
} else {
cursor
};
if let Some(range) = buf.find_next(from, &re) {
if !state.wrap_around && range.start.line < cursor.line {
return false;
}
Cursor::set_cursor(buf, range.start);
return true;
}
false
}
pub fn search_backward<B: Cursor + Query + Search>(
buf: &mut B,
state: &mut SearchState,
skip_current: bool,
) -> bool {
let Some(re) = state.pattern.clone() else {
return false;
};
let cursor = buf.cursor();
let total = buf.line_count();
if total == 0 {
return false;
}
let initial = buf.find_prev(cursor, &re);
let range = if skip_current {
match initial {
Some(m) if m.start == cursor => {
let cb = buf.byte_offset(m.start);
if cb == 0 {
None
} else {
let anchor = buf.pos_at_byte(cb.saturating_sub(1));
buf.find_prev(anchor, &re)
}
}
other => other,
}
} else {
initial
};
if let Some(range) = range {
if !state.wrap_around && range.start.line > cursor.line {
return false;
}
Cursor::set_cursor(buf, range.start);
return true;
}
false
}
pub fn search_matches<B: Query>(
buf: &B,
state: &mut SearchState,
dirty_gen: u64,
row: usize,
) -> Vec<(usize, usize)> {
if state.pattern.is_none() {
return Vec::new();
}
let line_count = buf.line_count() as usize;
if row >= line_count {
return Vec::new();
}
let line = buf.line(row as u32);
state.matches_for(row, &line, dirty_gen).to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Pos;
use hjkl_buffer::Buffer;
fn re(pat: &str) -> Regex {
Regex::new(pat).unwrap()
}
fn vim_re(pat: &str) -> Regex {
Regex::new(&vim_to_rust_regex(pat)).unwrap()
}
#[test]
fn vim_boundary_rewrites_to_b() {
assert_eq!(vim_to_rust_regex(r"\<foo\>"), r"\bfoo\b");
assert_eq!(vim_to_rust_regex(r"\<"), r"\b");
assert_eq!(vim_to_rust_regex(r"\>"), r"\b");
}
#[test]
fn escaped_backslash_left_alone() {
let input = r"\\<";
let output = vim_to_rust_regex(input);
assert_eq!(output, r"\\<");
}
#[test]
fn other_escapes_unchanged() {
assert_eq!(vim_to_rust_regex(r"\b"), r"\b");
assert_eq!(vim_to_rust_regex(r"\B"), r"\B");
assert_eq!(vim_to_rust_regex(r"\d+"), r"\d+");
assert_eq!(vim_to_rust_regex(r"^\w+$"), r"^\w+$");
}
#[test]
fn mixed_boundary_and_word_class() {
assert_eq!(vim_to_rust_regex(r"\<\w+\>"), r"\b\w+\b");
}
#[test]
fn vim_boundary_matches_standalone_word_not_suffix() {
let re = vim_re(r"foo\<bar\>");
assert!(!re.is_match("foobar"));
let re2 = vim_re(r"\<bar\>");
assert!(re2.is_match("foo bar baz"));
assert!(!re2.is_match("foobar"));
}
#[test]
fn vim_boundary_start_only() {
let re = vim_re(r"\<word");
assert!(re.is_match("word here"));
assert!(re.is_match("some word here"));
assert!(!re.is_match("sword"));
assert!(!re.is_match("aword"));
}
#[test]
fn vim_boundary_end_only() {
let re = vim_re(r"word\>");
assert!(re.is_match("some word"));
assert!(re.is_match("word"));
assert!(!re.is_match("words"));
assert!(!re.is_match("wordsmith"));
}
#[test]
fn existing_b_boundary_unchanged() {
let re = vim_re(r"\bfoo\b");
assert!(re.is_match("foo"));
assert!(re.is_match("a foo b"));
assert!(!re.is_match("foobar"));
assert!(!re.is_match("afoo"));
}
#[test]
fn vim_whole_word_pattern() {
let re = vim_re(r"\<\w+\>");
let matches: Vec<_> = re.find_iter("foo bar baz").map(|m| m.as_str()).collect();
assert_eq!(matches, vec!["foo", "bar", "baz"]);
}
#[test]
fn empty_state_no_match() {
let mut b = Buffer::from_str("anything");
let mut s = SearchState::new();
assert!(!search_forward(&mut b, &mut s, false));
assert!(!search_backward(&mut b, &mut s, false));
}
#[test]
fn forward_finds_first_match() {
let mut b = Buffer::from_str("foo bar foo baz");
let mut s = SearchState::new();
s.set_pattern(Some(re("foo")));
assert!(search_forward(&mut b, &mut s, false));
assert_eq!(Cursor::cursor(&b), Pos::new(0, 0));
}
#[test]
fn forward_skip_current_walks_past() {
let mut b = Buffer::from_str("foo bar foo baz");
let mut s = SearchState::new();
s.set_pattern(Some(re("foo")));
search_forward(&mut b, &mut s, false);
search_forward(&mut b, &mut s, true);
assert_eq!(Cursor::cursor(&b), Pos::new(0, 8));
}
#[test]
fn forward_wraps_to_top() {
let mut b = Buffer::from_str("zzz\nfoo");
Cursor::set_cursor(&mut b, Pos::new(1, 2));
let mut s = SearchState::new();
s.set_pattern(Some(re("zzz")));
s.wrap_around = true;
assert!(search_forward(&mut b, &mut s, true));
assert_eq!(Cursor::cursor(&b), Pos::new(0, 0));
}
#[test]
fn search_matches_caches_against_dirty_gen() {
let b = Buffer::from_str("foo bar");
let mut s = SearchState::new();
s.set_pattern(Some(re("bar")));
let dgen = b.dirty_gen();
let initial = search_matches(&b, &mut s, dgen, 0);
assert_eq!(initial, vec![(4, 7)]);
}
#[test]
fn case_mode_from_options_matrix() {
assert_eq!(CaseMode::from_options(false, false), CaseMode::Sensitive);
assert_eq!(CaseMode::from_options(false, true), CaseMode::Sensitive);
assert_eq!(CaseMode::from_options(true, false), CaseMode::Insensitive);
assert_eq!(CaseMode::from_options(true, true), CaseMode::Smart);
}
#[test]
fn resolve_case_mode_no_override_smart_lowercase() {
let (stripped, mode) = resolve_case_mode("foo", CaseMode::Smart);
assert_eq!(stripped, "foo");
assert_eq!(mode, CaseMode::Insensitive);
}
#[test]
fn resolve_case_mode_no_override_smart_uppercase() {
let (stripped, mode) = resolve_case_mode("Foo", CaseMode::Smart);
assert_eq!(stripped, "Foo");
assert_eq!(mode, CaseMode::Sensitive);
}
#[test]
fn resolve_case_mode_lower_c_override() {
let (stripped, mode) = resolve_case_mode(r"\cFoo", CaseMode::Sensitive);
assert_eq!(stripped, "Foo");
assert_eq!(mode, CaseMode::Insensitive);
}
#[test]
fn resolve_case_mode_upper_c_override() {
let (stripped, mode) = resolve_case_mode(r"foo\C", CaseMode::Smart);
assert_eq!(stripped, "foo");
assert_eq!(mode, CaseMode::Sensitive);
}
#[test]
fn resolve_case_mode_last_wins() {
let (stripped, mode) = resolve_case_mode(r"\cfoo\C", CaseMode::Smart);
assert_eq!(stripped, "foo");
assert_eq!(mode, CaseMode::Sensitive);
}
fn build_regex_from(pat: &str, ic: bool, smart: bool) -> Regex {
let base = CaseMode::from_options(ic, smart);
let (stripped, mode) = resolve_case_mode(pat, base);
let src = if mode == CaseMode::Insensitive {
format!("(?i){stripped}")
} else {
stripped
};
Regex::new(&src).unwrap()
}
#[test]
fn search_finds_capital_with_smartcase_lowercase_pattern() {
let re = build_regex_from("foo", true, true);
assert!(re.is_match("FOO"), "expected match on 'FOO'");
assert!(re.is_match("foo"), "expected match on 'foo'");
}
#[test]
fn search_skips_capital_with_smartcase_mixed_pattern() {
let re = build_regex_from("Foo", true, true);
assert!(!re.is_match("FOO"), "must not match 'FOO' (case-sensitive)");
assert!(re.is_match("Foo"), "must match exact 'Foo'");
}
#[test]
fn search_lower_c_override_finds_capital() {
let re = build_regex_from(r"\cFoo", false, false);
assert!(re.is_match("FOO"), "\\c override must match 'FOO'");
assert!(re.is_match("foo"), "\\c override must match 'foo'");
}
#[test]
fn vim_to_rust_regex_strips_case_overrides() {
assert_eq!(vim_to_rust_regex(r"\cfoo"), "foo");
assert_eq!(vim_to_rust_regex(r"foo\C"), "foo");
assert_eq!(vim_to_rust_regex(r"\<bar\>"), r"\bbar\b");
}
#[test]
fn star_search_finds_lowercase_when_smartcase_lower_word() {
let pat = r"\bfoo\b";
let re = build_regex_from(pat, true, true);
let text = "FOO foo Foo";
let hits: Vec<_> = re.find_iter(text).map(|m| m.as_str()).collect();
assert!(
hits.contains(&"FOO"),
"smartcase lower-word * must match FOO: {hits:?}"
);
assert!(
hits.contains(&"foo"),
"smartcase lower-word * must match foo: {hits:?}"
);
}
}