use crate::pattern::SearchPattern;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
pub enum Direction {
#[default]
Forward,
Backward,
}
impl Direction {
#[must_use]
pub const fn reversed(self) -> Self {
match self {
Self::Forward => Self::Backward,
Self::Backward => Self::Forward,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct SearchMatch {
pub start: usize,
pub end: usize,
}
impl SearchMatch {
#[must_use]
pub const fn len(&self) -> usize {
self.end - self.start
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.start == self.end
}
#[must_use]
pub const fn contains(&self, offset: usize) -> bool {
offset >= self.start && offset < self.end
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Wrapped {
No,
AtBottom,
AtTop,
}
impl Wrapped {
#[must_use]
pub const fn message(self) -> Option<&'static str> {
match self {
Self::No => None,
Self::AtBottom => Some("search hit BOTTOM, continuing at TOP"),
Self::AtTop => Some("search hit TOP, continuing at BOTTOM"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Step {
pub target: SearchMatch,
pub index: usize,
pub wrapped: Wrapped,
}
#[must_use]
pub fn find_all(text: &str, pattern: &SearchPattern) -> Vec<SearchMatch> {
let mut byte_to_char = vec![0usize; text.len() + 1];
for (char_idx, (byte_idx, _)) in text.char_indices().enumerate() {
byte_to_char[byte_idx] = char_idx;
}
byte_to_char[text.len()] = text.chars().count();
let mut last = 0;
for slot in &mut byte_to_char {
if *slot == 0 && last != 0 {
*slot = last;
} else {
last = *slot;
}
}
pattern
.regex()
.find_iter(text)
.map(|m| SearchMatch {
start: byte_to_char[m.start()],
end: byte_to_char[m.end()],
})
.collect()
}
#[must_use]
pub fn step(matches: &[SearchMatch], from: usize, direction: Direction) -> Option<Step> {
if matches.is_empty() {
return None;
}
match direction {
Direction::Forward => matches
.iter()
.position(|m| m.start > from)
.map(|i| Step {
target: matches[i],
index: i,
wrapped: Wrapped::No,
})
.or(Some(Step {
target: matches[0],
index: 0,
wrapped: Wrapped::AtBottom,
})),
Direction::Backward => matches
.iter()
.rposition(|m| m.start < from)
.map(|i| Step {
target: matches[i],
index: i,
wrapped: Wrapped::No,
})
.or_else(|| {
let i = matches.len() - 1;
Some(Step {
target: matches[i],
index: i,
wrapped: Wrapped::AtTop,
})
}),
}
}
#[must_use]
pub fn step_inclusive(matches: &[SearchMatch], from: usize, direction: Direction) -> Option<Step> {
if matches.is_empty() {
return None;
}
match direction {
Direction::Forward => matches
.iter()
.position(|m| m.start >= from)
.map(|i| Step {
target: matches[i],
index: i,
wrapped: Wrapped::No,
})
.or(Some(Step {
target: matches[0],
index: 0,
wrapped: Wrapped::AtBottom,
})),
Direction::Backward => matches
.iter()
.rposition(|m| m.start <= from)
.map(|i| Step {
target: matches[i],
index: i,
wrapped: Wrapped::No,
})
.or_else(|| {
let i = matches.len() - 1;
Some(Step {
target: matches[i],
index: i,
wrapped: Wrapped::AtTop,
})
}),
}
}
#[must_use]
pub fn word_at(text: &str, cursor: usize) -> Option<String> {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return None;
}
let is_word = |c: char| c.is_alphanumeric() || c == '_';
let mut i = cursor.min(chars.len().saturating_sub(1));
while i < chars.len() && !is_word(chars[i]) {
if chars[i] == '\n' {
return None;
}
i += 1;
}
if i >= chars.len() {
return None;
}
let mut start = i;
while start > 0 && is_word(chars[start - 1]) {
start -= 1;
}
let mut end = i;
while end < chars.len() && is_word(chars[end]) {
end += 1;
}
Some(chars[start..end].iter().collect())
}
pub const MAX_COUNT: usize = 99;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchCount {
Idle,
None,
Exact { current: usize, total: usize },
Capped { current: usize },
}
impl MatchCount {
#[must_use]
pub const fn new(index: usize, total: usize) -> Self {
if total == 0 || index >= total {
return Self::None;
}
if total > MAX_COUNT {
return Self::Capped { current: index + 1 };
}
Self::Exact {
current: index + 1,
total,
}
}
#[must_use]
pub const fn is_idle(self) -> bool {
matches!(self, Self::Idle)
}
pub fn render_into(self, out: &mut String) {
match self {
Self::Idle => {}
Self::None => out.push_str("[0/0]"),
Self::Exact { current, total } => {
out.push('[');
push_usize(out, current);
out.push('/');
push_usize(out, total);
out.push(']');
}
Self::Capped { current } => {
out.push('[');
push_usize(out, current);
out.push_str("/>");
push_usize(out, MAX_COUNT);
out.push(']');
}
}
}
}
fn push_usize(out: &mut String, mut n: usize) {
if n == 0 {
out.push('0');
return;
}
let mut buf = [0u8; 20];
let mut i = buf.len();
while n > 0 {
i -= 1;
buf[i] = b'0' + u8::try_from(n % 10).unwrap_or(0);
n /= 10;
}
out.push_str(core::str::from_utf8(&buf[i..]).unwrap_or("?"));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pattern::CaseMode;
fn pat(p: &str) -> SearchPattern {
SearchPattern::compile(p, CaseMode::Sensitive).unwrap()
}
#[test]
fn finds_every_occurrence_in_order() {
let m = find_all("foo bar foo baz foo", &pat("foo"));
assert_eq!(m.len(), 3);
assert_eq!(m[0], SearchMatch { start: 0, end: 3 });
assert_eq!(m[1], SearchMatch { start: 8, end: 11 });
assert_eq!(m[2], SearchMatch { start: 16, end: 19 });
}
#[test]
fn offsets_are_chars_not_bytes() {
let text = "héllo foo";
let m = find_all(text, &pat("foo"));
assert_eq!(m.len(), 1);
assert_eq!(m[0].start, 6, "char offset; byte offset would be 7");
let got: String = text.chars().skip(m[0].start).take(m[0].len()).collect();
assert_eq!(got, "foo");
}
#[test]
fn multibyte_heavy_text_stays_aligned() {
let text = "日本語 foo 日本語 foo";
let m = find_all(text, &pat("foo"));
assert_eq!(m.len(), 2);
for mm in &m {
let got: String = text.chars().skip(mm.start).take(mm.len()).collect();
assert_eq!(got, "foo");
}
}
#[test]
fn no_matches_is_empty_not_a_panic() {
assert!(find_all("abc", &pat("zzz")).is_empty());
assert!(step(&[], 0, Direction::Forward).is_none());
}
#[test]
fn forward_advances_past_a_match_the_cursor_sits_on() {
let m = find_all("foo foo foo", &pat("foo"));
let s = step(&m, 0, Direction::Forward).unwrap();
assert_eq!(s.target.start, 4);
assert_eq!(s.index, 1);
assert_eq!(s.wrapped, Wrapped::No);
}
#[test]
fn forward_wraps_at_the_bottom_and_says_so() {
let m = find_all("foo foo", &pat("foo"));
let s = step(&m, 100, Direction::Forward).unwrap();
assert_eq!(s.target.start, 0);
assert_eq!(s.wrapped, Wrapped::AtBottom);
assert!(s.wrapped.message().unwrap().contains("BOTTOM"));
}
#[test]
fn backward_finds_the_previous_match() {
let m = find_all("foo foo foo", &pat("foo"));
let s = step(&m, 8, Direction::Backward).unwrap();
assert_eq!(s.target.start, 4);
assert_eq!(s.wrapped, Wrapped::No);
}
#[test]
fn backward_wraps_at_the_top_and_says_so() {
let m = find_all("foo foo", &pat("foo"));
let s = step(&m, 0, Direction::Backward).unwrap();
assert_eq!(s.target.start, 4);
assert_eq!(s.wrapped, Wrapped::AtTop);
assert!(s.wrapped.message().unwrap().contains("TOP"));
}
#[test]
fn a_lone_match_resolves_to_itself_by_wrapping() {
let m = find_all("hello foo world", &pat("foo"));
assert_eq!(m.len(), 1);
for dir in [Direction::Forward, Direction::Backward] {
let s = step(&m, m[0].start, dir).unwrap();
assert_eq!(
s.target, m[0],
"single match must resolve to itself ({dir:?})"
);
assert_ne!(s.wrapped, Wrapped::No, "and must report the wrap");
}
}
#[test]
fn step_inclusive_finds_a_match_starting_at_the_cursor() {
let m = find_all("foo foo foo", &pat("foo"));
assert_eq!(step(&m, 0, Direction::Forward).unwrap().target.start, 4);
assert_eq!(
step_inclusive(&m, 0, Direction::Forward)
.unwrap()
.target
.start,
0
);
}
#[test]
fn step_inclusive_at_offset_zero_is_reachable() {
let m = find_all("foo bar", &pat("foo"));
let s = step_inclusive(&m, 0, Direction::Forward).unwrap();
assert_eq!(s.target.start, 0);
assert_eq!(
s.wrapped,
Wrapped::No,
"reaching it must not count as a wrap"
);
}
#[test]
fn step_inclusive_backward_also_accepts_the_cursor_position() {
let m = find_all("foo foo foo", &pat("foo"));
assert_eq!(step(&m, 8, Direction::Backward).unwrap().target.start, 4);
assert_eq!(
step_inclusive(&m, 8, Direction::Backward)
.unwrap()
.target
.start,
8
);
}
#[test]
fn step_inclusive_on_no_matches_is_none() {
assert!(step_inclusive(&[], 0, Direction::Forward).is_none());
}
#[test]
fn direction_reverses() {
assert_eq!(Direction::Forward.reversed(), Direction::Backward);
assert_eq!(Direction::Backward.reversed(), Direction::Forward);
}
#[test]
fn zero_width_matches_terminate_and_never_highlight() {
let m = find_all("abc", &pat("x*"));
assert!(!m.is_empty());
assert!(m.iter().all(SearchMatch::is_empty));
assert!(!m[0].contains(0), "a zero-width match highlights nothing");
}
#[test]
fn contains_is_half_open() {
let m = SearchMatch { start: 2, end: 5 };
assert!(!m.contains(1));
assert!(m.contains(2));
assert!(m.contains(4));
assert!(!m.contains(5), "end is exclusive");
}
#[test]
fn word_at_reads_the_whole_word_from_inside_it() {
assert_eq!(word_at("hello world", 2).as_deref(), Some("hello"));
assert_eq!(word_at("hello world", 0).as_deref(), Some("hello"));
assert_eq!(word_at("hello world", 4).as_deref(), Some("hello"));
assert_eq!(word_at("hello world", 8).as_deref(), Some("world"));
}
#[test]
fn word_at_scans_forward_from_whitespace_like_vim() {
assert_eq!(word_at(" hello", 0).as_deref(), Some("hello"));
}
#[test]
fn word_at_stops_at_the_line_end() {
assert_eq!(word_at(" \nhello", 0), None);
}
#[test]
fn word_at_includes_underscores_and_digits() {
assert_eq!(word_at("foo_bar99 x", 0).as_deref(), Some("foo_bar99"));
}
#[test]
fn word_at_on_empty_text_is_none() {
assert_eq!(word_at("", 0), None);
}
#[test]
fn case_insensitive_search_finds_mixed_case() {
let p = SearchPattern::compile("foo", CaseMode::Ignore).unwrap();
assert_eq!(find_all("Foo FOO foo", &p).len(), 3);
}
#[test]
fn smartcase_capital_narrows_the_result_set() {
let loose = SearchPattern::compile("foo", CaseMode::Smart).unwrap();
let tight = SearchPattern::compile("Foo", CaseMode::Smart).unwrap();
assert_eq!(find_all("Foo FOO foo", &loose).len(), 3);
assert_eq!(find_all("Foo FOO foo", &tight).len(), 1);
}
#[test]
fn stepping_forward_through_every_match_returns_to_the_start() {
let text = "a foo b foo c foo d";
let m = find_all(text, &pat("foo"));
let mut at = 0;
let mut seen = vec![];
for _ in 0..m.len() {
let s = step(&m, at, Direction::Forward).unwrap();
seen.push(s.target.start);
at = s.target.start;
}
assert_eq!(seen, vec![2, 8, 14]);
assert_eq!(step(&m, at, Direction::Forward).unwrap().target.start, 2);
}
}