use crate::diff::DiffLine;
#[derive(Debug, Clone)]
pub struct SearchMatch {
pub line_idx: usize,
pub char_start: usize,
pub char_len: usize,
}
#[derive(Debug)]
pub struct SearchState {
pub query: String,
pub input_active: bool,
pub matches: Vec<SearchMatch>,
pub current: usize,
pub visible_count: usize,
pub visible_position: usize,
}
impl Default for SearchState {
fn default() -> Self {
Self {
query: String::new(),
input_active: true,
matches: Vec::new(),
current: 0,
visible_count: 0,
visible_position: 0,
}
}
}
impl SearchState {
pub fn new() -> Self {
Self::default()
}
pub fn match_count(&self) -> usize {
self.matches.len()
}
pub fn current_display(&self) -> usize {
if self.visible_count == 0 {
0
} else {
self.visible_position + 1
}
}
pub fn update_visibility(&mut self, visible_lines: &std::collections::HashSet<usize>) {
if self.matches.is_empty() {
self.visible_count = 0;
self.visible_position = 0;
return;
}
self.visible_count = self.matches.iter().filter(|m| visible_lines.contains(&m.line_idx)).count();
self.visible_position = self.matches[..=self.current.min(self.matches.len() - 1)]
.iter()
.filter(|m| visible_lines.contains(&m.line_idx))
.count()
.saturating_sub(1);
}
}
pub fn compute_matches(lines: &[DiffLine], query: &str) -> Vec<SearchMatch> {
if query.is_empty() {
return vec![];
}
let query_lower = query.to_lowercase();
let query_char_len = query_lower.chars().count();
let mut matches = Vec::new();
for (idx, line) in lines.iter().enumerate() {
let content_lower = line.content.to_lowercase();
let mut byte_start = 0;
while let Some(byte_pos) = content_lower[byte_start..].find(&query_lower) {
let abs_byte = byte_start + byte_pos;
let char_start = content_lower[..abs_byte].chars().count();
matches.push(SearchMatch {
line_idx: idx,
char_start,
char_len: query_char_len,
});
byte_start = abs_byte + content_lower[abs_byte..].chars().next().map_or(1, |c| c.len_utf8());
}
}
matches
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::{DiffLine, LineSource};
fn make_line(content: &str) -> DiffLine {
DiffLine::new(LineSource::Base, content.to_string(), ' ', None)
}
fn lines(contents: &[&str]) -> Vec<DiffLine> {
contents.iter().map(|c| make_line(c)).collect()
}
#[test]
fn empty_query_returns_no_matches() {
let l = lines(&["hello world"]);
assert!(compute_matches(&l, "").is_empty());
}
#[test]
fn no_match_returns_empty() {
let l = lines(&["hello world"]);
assert!(compute_matches(&l, "xyz").is_empty());
}
#[test]
fn single_match() {
let l = lines(&["hello world"]);
let m = compute_matches(&l, "world");
assert_eq!(m.len(), 1);
assert_eq!(m[0].line_idx, 0);
assert_eq!(m[0].char_start, 6);
assert_eq!(m[0].char_len, 5);
}
#[test]
fn case_insensitive() {
let l = lines(&["Hello World"]);
let m = compute_matches(&l, "hello");
assert_eq!(m.len(), 1);
assert_eq!(m[0].char_start, 0);
}
#[test]
fn multiple_matches_same_line() {
let l = lines(&["aaa"]);
let m = compute_matches(&l, "aa");
assert_eq!(m.len(), 2);
assert_eq!(m[0].char_start, 0);
assert_eq!(m[1].char_start, 1);
}
#[test]
fn matches_across_lines() {
let l = lines(&["foo bar", "baz foo", "nothing"]);
let m = compute_matches(&l, "foo");
assert_eq!(m.len(), 2);
assert_eq!(m[0].line_idx, 0);
assert_eq!(m[0].char_start, 0);
assert_eq!(m[1].line_idx, 1);
assert_eq!(m[1].char_start, 4);
}
#[test]
fn empty_lines_skipped() {
let l = lines(&["", "hello", ""]);
let m = compute_matches(&l, "hello");
assert_eq!(m.len(), 1);
assert_eq!(m[0].line_idx, 1);
}
#[test]
fn multibyte_unicode_uses_char_offsets() {
let l = lines(&["café résumé"]);
let m = compute_matches(&l, "résumé");
assert_eq!(m.len(), 1);
assert_eq!(m[0].char_start, 5);
assert_eq!(m[0].char_len, 6);
}
#[test]
fn case_insensitive_multibyte() {
let l = lines(&["ÜBER cool"]);
let m = compute_matches(&l, "über");
assert_eq!(m.len(), 1);
assert_eq!(m[0].char_start, 0);
assert_eq!(m[0].char_len, 4);
}
#[test]
fn search_state_display_index() {
use std::collections::HashSet;
let mut s = SearchState::new();
assert_eq!(s.current_display(), 0);
s.matches.push(SearchMatch { line_idx: 0, char_start: 0, char_len: 3 });
s.matches.push(SearchMatch { line_idx: 1, char_start: 0, char_len: 3 });
let all_visible: HashSet<usize> = [0, 1].into();
s.current = 0;
s.update_visibility(&all_visible);
assert_eq!(s.current_display(), 1);
s.current = 1;
s.update_visibility(&all_visible);
assert_eq!(s.current_display(), 2);
}
#[test]
fn search_state_display_with_hidden_matches() {
use std::collections::HashSet;
let mut s = SearchState::new();
s.matches.push(SearchMatch { line_idx: 0, char_start: 0, char_len: 3 });
s.matches.push(SearchMatch { line_idx: 1, char_start: 0, char_len: 3 });
s.matches.push(SearchMatch { line_idx: 2, char_start: 0, char_len: 3 });
let visible: HashSet<usize> = [0, 2].into();
s.current = 0;
s.update_visibility(&visible);
assert_eq!(s.visible_count, 2);
assert_eq!(s.current_display(), 1);
s.current = 2;
s.update_visibility(&visible);
assert_eq!(s.current_display(), 2);
}
#[test]
fn update_visibility_empty_matches_does_not_panic() {
use std::collections::HashSet;
let mut s = SearchState::new();
let visible: HashSet<usize> = [0, 1, 2].into();
s.update_visibility(&visible);
assert_eq!(s.visible_count, 0);
assert_eq!(s.visible_position, 0);
assert_eq!(s.current_display(), 0);
}
}