use crate::text_buffer::TextBuffer;
use iced::widget::Id;
use std::borrow::Cow;
use std::thread;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SearchMatch {
pub line: usize,
pub col: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchFocusedField {
Search,
Replace,
}
#[derive(Debug, Clone)]
pub struct SearchState {
pub query: String,
pub replace_with: String,
pub case_sensitive: bool,
pub is_open: bool,
pub is_replace_mode: bool,
pub matches: Vec<SearchMatch>,
pub current_match_index: Option<usize>,
pub search_input_id: Id,
pub replace_input_id: Id,
pub focused_field: SearchFocusedField,
buffer_line_count: usize,
}
impl Default for SearchState {
fn default() -> Self {
Self {
query: String::new(),
replace_with: String::new(),
case_sensitive: false,
is_open: false,
is_replace_mode: false,
matches: Vec::new(),
current_match_index: None,
search_input_id: Id::unique(),
replace_input_id: Id::unique(),
focused_field: SearchFocusedField::Search,
buffer_line_count: 0,
}
}
}
impl SearchState {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn open_search(&mut self) {
self.is_open = true;
self.is_replace_mode = false;
self.focused_field = SearchFocusedField::Search;
}
pub fn open_replace(&mut self) {
self.is_open = true;
self.is_replace_mode = true;
self.focused_field = SearchFocusedField::Search;
}
pub fn close(&mut self) {
self.is_open = false;
}
pub fn focus_next_field(&mut self) {
if self.is_replace_mode {
self.focused_field = match self.focused_field {
SearchFocusedField::Search => SearchFocusedField::Replace,
SearchFocusedField::Replace => SearchFocusedField::Search,
};
}
}
pub fn focus_previous_field(&mut self) {
if self.is_replace_mode {
self.focused_field = match self.focused_field {
SearchFocusedField::Search => SearchFocusedField::Replace,
SearchFocusedField::Replace => SearchFocusedField::Search,
};
}
}
pub fn set_query(&mut self, query: String, buffer: &TextBuffer) {
self.query = query;
self.update_matches(buffer);
}
pub fn set_replace_with(&mut self, replace_with: String) {
self.replace_with = replace_with;
}
pub fn toggle_case_sensitive(&mut self, buffer: &TextBuffer) {
self.case_sensitive = !self.case_sensitive;
self.update_matches(buffer);
}
pub fn update_matches(&mut self, buffer: &TextBuffer) {
self.matches = find_matches(
buffer,
&self.query,
self.case_sensitive,
Some(MAX_MATCHES),
);
self.buffer_line_count = buffer.line_count();
if self.matches.is_empty() {
self.current_match_index = None;
} else if self.current_match_index.is_none() {
self.current_match_index = Some(0);
} else if let Some(idx) = self.current_match_index {
if idx >= self.matches.len() {
self.current_match_index =
Some(self.matches.len().saturating_sub(1));
}
}
}
pub(crate) fn update_matches_after_edit(
&mut self,
buffer: &TextBuffer,
start_line: usize,
old_end_exclusive: usize,
) {
if self.query.is_empty() || self.buffer_line_count == 0 {
self.update_matches(buffer);
return;
}
let old_line_count = self.buffer_line_count;
let new_line_count = buffer.line_count();
let start_line = start_line.min(old_line_count).min(new_line_count);
let old_end_exclusive =
old_end_exclusive.min(old_line_count).max(start_line);
let new_end_exclusive = if new_line_count >= old_line_count {
old_end_exclusive
.saturating_add(new_line_count - old_line_count)
.min(new_line_count)
} else {
old_end_exclusive
.saturating_sub(old_line_count - new_line_count)
.max(start_line)
.min(new_line_count)
};
let replace_start =
self.matches.partition_point(|item| item.line < start_line);
let replace_end =
self.matches.partition_point(|item| item.line < old_end_exclusive);
let replacement = find_matches_in_range(
buffer,
&self.query,
self.case_sensitive,
start_line,
new_end_exclusive,
Some(MAX_MATCHES),
);
let replacement_len = replacement.len();
self.matches.splice(replace_start..replace_end, replacement);
let shifted_suffix_start = replace_start + replacement_len;
for item in &mut self.matches[shifted_suffix_start..] {
item.line = if new_line_count >= old_line_count {
item.line.saturating_add(new_line_count - old_line_count)
} else {
item.line.saturating_sub(old_line_count - new_line_count)
};
}
self.matches.truncate(MAX_MATCHES);
self.buffer_line_count = new_line_count;
if self.matches.is_empty() {
self.current_match_index = None;
}
}
pub fn next_match(&mut self) {
if self.matches.is_empty() {
return;
}
self.current_match_index = Some(match self.current_match_index {
Some(idx) => {
if idx + 1 >= self.matches.len() {
0 } else {
idx + 1
}
}
None => 0,
});
}
pub fn previous_match(&mut self) {
if self.matches.is_empty() {
return;
}
self.current_match_index = Some(match self.current_match_index {
Some(idx) => {
if idx == 0 {
self.matches.len() - 1 } else {
idx - 1
}
}
None => self.matches.len() - 1,
});
}
#[must_use]
pub fn current_match(&self) -> Option<SearchMatch> {
self.current_match_index.and_then(|idx| self.matches.get(idx).copied())
}
#[must_use]
pub fn match_count(&self) -> usize {
self.matches.len()
}
pub fn select_match_at_cursor(
&mut self,
cursor: (usize, usize),
selection: Option<((usize, usize), (usize, usize))>,
) -> bool {
if self.matches.is_empty() || self.query.is_empty() {
return false;
}
let query_len = self.query.chars().count();
let exact_selection_index = selection.and_then(|(start, end)| {
if start.0 != end.0 {
return None;
}
self.matches_on_line(start.0).find(|&index| {
let match_item = self.matches[index];
match_item.col == start.1
&& match_item.col.saturating_add(query_len) == end.1
})
});
let cursor_index = exact_selection_index.or_else(|| {
let line_matches = self.matches_on_line(cursor.0);
line_matches
.clone()
.find(|&index| {
let match_item = self.matches[index];
(match_item.col..=match_item.col.saturating_add(query_len))
.contains(&cursor.1)
})
.or_else(|| {
line_matches.min_by_key(|&index| {
let match_item = self.matches[index];
let match_end =
match_item.col.saturating_add(query_len);
if cursor.1 < match_item.col {
match_item.col - cursor.1
} else {
cursor.1.saturating_sub(match_end)
}
})
})
});
let Some(index) = cursor_index else {
return false;
};
if self.current_match_index == Some(index) {
return false;
}
self.current_match_index = Some(index);
true
}
fn matches_on_line(&self, line: usize) -> std::ops::Range<usize> {
let start = self.matches.partition_point(|item| item.line < line);
let end = self.matches.partition_point(|item| item.line <= line);
start..end
}
pub fn select_match_near_cursor(&mut self, cursor: (usize, usize)) {
if self.matches.is_empty() {
self.current_match_index = None;
return;
}
let (cursor_line, cursor_col) = cursor;
let insertion = self.matches.partition_point(|item| {
(item.line, item.col) < (cursor_line, cursor_col)
});
let mut left = insertion.checked_sub(1);
let mut right = (insertion < self.matches.len()).then_some(insertion);
let mut closest_index = insertion.min(self.matches.len() - 1);
let mut closest_distance = usize::MAX;
while left.is_some() || right.is_some() {
let left_line_distance = left.map_or(usize::MAX, |index| {
self.matches[index]
.line
.abs_diff(cursor_line)
.saturating_mul(1000)
});
let right_line_distance = right.map_or(usize::MAX, |index| {
self.matches[index]
.line
.abs_diff(cursor_line)
.saturating_mul(1000)
});
if left_line_distance.min(right_line_distance) > closest_distance {
break;
}
let index = match (left, right) {
(Some(index), Some(_))
if left_line_distance <= right_line_distance =>
{
left = index.checked_sub(1);
index
}
(_, Some(index)) => {
right =
(index + 1 < self.matches.len()).then_some(index + 1);
index
}
(Some(index), None) => {
left = index.checked_sub(1);
index
}
(None, None) => break,
};
let item = self.matches[index];
let distance = item
.line
.abs_diff(cursor_line)
.saturating_mul(1000)
.saturating_add(item.col.abs_diff(cursor_col));
if distance < closest_distance {
closest_distance = distance;
closest_index = index;
if distance == 0 {
break;
}
}
}
self.current_match_index = Some(closest_index);
}
}
#[must_use]
pub fn find_matches(
buffer: &TextBuffer,
query: &str,
case_sensitive: bool,
limit: Option<usize>,
) -> Vec<SearchMatch> {
if query.is_empty() {
return Vec::new();
}
let line_count = buffer.line_count();
if line_count > PARALLEL_SEARCH_THRESHOLD {
let num_threads =
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
if num_threads > 1 {
let chunk_size = line_count.div_ceil(num_threads);
return thread::scope(|s| {
let mut handles = Vec::with_capacity(num_threads);
for i in 0..num_threads {
let start = i * chunk_size;
let end = (start + chunk_size).min(line_count);
if start >= end {
break;
}
handles.push(s.spawn(move || {
find_matches_in_range(
buffer,
query,
case_sensitive,
start,
end,
limit,
)
}));
}
let mut matches = Vec::new();
for handle in handles {
if let Ok(mut chunk_matches) = handle.join() {
matches.append(&mut chunk_matches);
if let Some(l) = limit
&& matches.len() >= l
{
matches.truncate(l);
break;
}
}
}
matches
});
}
}
find_matches_in_range(buffer, query, case_sensitive, 0, line_count, limit)
}
const PARALLEL_SEARCH_THRESHOLD: usize = 1000;
pub const MAX_MATCHES: usize = 10_000;
pub fn get_visible_match_range(
matches: &[SearchMatch],
min_logical_line: usize,
max_logical_line: usize,
) -> std::ops::Range<usize> {
if matches.is_empty() {
return 0..0;
}
let start_idx = matches.partition_point(|m| m.line < min_logical_line);
let mut end_idx = start_idx;
for match_item in matches.iter().skip(start_idx) {
if match_item.line > max_logical_line {
break;
}
end_idx += 1;
}
start_idx..end_idx
}
fn find_matches_in_range(
buffer: &TextBuffer,
query: &str,
case_sensitive: bool,
start_line: usize,
end_line: usize,
limit: Option<usize>,
) -> Vec<SearchMatch> {
let mut matches = Vec::new();
let search_query = if case_sensitive {
Cow::Borrowed(query)
} else {
Cow::Owned(query.to_lowercase())
};
for line_idx in start_line..end_line {
if let Some(l) = limit
&& matches.len() >= l
{
break;
}
let line = buffer.line(line_idx);
if line.len() < query.len() {
continue;
}
let search_line = if case_sensitive {
Cow::Borrowed(line)
} else {
Cow::Owned(line.to_lowercase())
};
let mut start_pos = 0;
while let Some(relative_pos) =
search_line[start_pos..].find(search_query.as_ref())
{
let absolute_pos = start_pos + relative_pos;
let col = if let Some(slice) = line.get(..absolute_pos) {
slice.chars().count()
} else {
search_line[..absolute_pos].chars().count()
};
matches.push(SearchMatch { line: line_idx, col });
start_pos = absolute_pos + search_query.len();
}
}
matches
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_matches_case_sensitive() {
let buffer = TextBuffer::new("Hello World\nhello world");
let matches = find_matches(&buffer, "hello", true, None);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].line, 1);
assert_eq!(matches[0].col, 0);
}
#[test]
fn test_find_matches_case_insensitive() {
let buffer = TextBuffer::new("Hello World\nhello world");
let matches = find_matches(&buffer, "hello", false, None);
assert_eq!(matches.len(), 2);
assert_eq!(matches[0].line, 0);
assert_eq!(matches[0].col, 0);
assert_eq!(matches[1].line, 1);
assert_eq!(matches[1].col, 0);
}
#[test]
fn test_find_matches_multiple_occurrences() {
let buffer = TextBuffer::new("foo bar foo baz foo");
let matches = find_matches(&buffer, "foo", false, None);
assert_eq!(matches.len(), 3);
assert_eq!(matches[0].col, 0);
assert_eq!(matches[1].col, 8);
assert_eq!(matches[2].col, 16);
}
#[test]
fn test_find_matches_multiline() {
let buffer = TextBuffer::new("line1\nfoo\nline3\nfoo");
let matches = find_matches(&buffer, "foo", false, None);
assert_eq!(matches.len(), 2);
assert_eq!(matches[0].line, 1);
assert_eq!(matches[1].line, 3);
}
#[test]
fn test_find_matches_empty_query() {
let buffer = TextBuffer::new("Hello World");
let matches = find_matches(&buffer, "", false, None);
assert_eq!(matches.len(), 0);
}
#[test]
fn test_find_matches_no_results() {
let buffer = TextBuffer::new("Hello World");
let matches = find_matches(&buffer, "xyz", false, None);
assert_eq!(matches.len(), 0);
}
#[test]
fn test_search_state_navigation() {
let buffer = TextBuffer::new("foo bar foo baz foo");
let mut state = SearchState::new();
state.set_query("foo".to_string(), &buffer);
assert_eq!(state.match_count(), 3);
assert_eq!(state.current_match_index, Some(0));
state.next_match();
assert_eq!(state.current_match_index, Some(1));
state.next_match();
assert_eq!(state.current_match_index, Some(2));
state.next_match();
assert_eq!(state.current_match_index, Some(0));
state.previous_match();
assert_eq!(state.current_match_index, Some(2));
}
#[test]
fn test_select_match_near_cursor_uses_sorted_candidates() {
let buffer = TextBuffer::new("foo\nnone\nfoo bar foo\nnone\nfoo");
let mut state = SearchState::new();
state.set_query("foo".to_string(), &buffer);
state.select_match_near_cursor((2, 7));
assert_eq!(state.current_match_index, Some(2));
assert_eq!(
state.current_match(),
Some(SearchMatch { line: 2, col: 8 })
);
state.select_match_near_cursor((4, 0));
assert_eq!(
state.current_match(),
Some(SearchMatch { line: 4, col: 0 })
);
}
#[test]
fn test_search_state_toggle_case() {
let buffer = TextBuffer::new("Hello hello");
let mut state = SearchState::new();
state.set_query("hello".to_string(), &buffer);
assert_eq!(state.match_count(), 2);
state.toggle_case_sensitive(&buffer);
assert_eq!(state.match_count(), 1);
state.toggle_case_sensitive(&buffer);
assert_eq!(state.match_count(), 2);
}
#[test]
fn test_incremental_match_update_replaces_only_affected_lines() {
let mut buffer = TextBuffer::new("foo\nfoo\nfoo");
let mut state = SearchState::new();
state.set_query("foo".to_string(), &buffer);
buffer.insert_char(1, 1, 'x');
state.update_matches_after_edit(&buffer, 1, 2);
assert_eq!(
state.matches,
vec![
SearchMatch { line: 0, col: 0 },
SearchMatch { line: 2, col: 0 },
]
);
}
#[test]
fn test_incremental_match_update_shifts_suffix_after_newline() {
let mut buffer = TextBuffer::new("foo\nbar\nfoo");
let mut state = SearchState::new();
state.set_query("foo".to_string(), &buffer);
buffer.insert_newline(0, 0);
state.update_matches_after_edit(&buffer, 0, 2);
assert_eq!(
state.matches,
vec![
SearchMatch { line: 1, col: 0 },
SearchMatch { line: 3, col: 0 },
]
);
}
#[test]
fn test_find_matches_large_buffer_parallel() {
let mut content = String::new();
let num_lines = PARALLEL_SEARCH_THRESHOLD * 2;
for i in 0..num_lines {
content.push_str(&format!("line {} foo\n", i));
}
let buffer = TextBuffer::new(&content);
let matches = find_matches(&buffer, "foo", false, None);
assert_eq!(matches.len(), num_lines);
assert_eq!(matches[0].line, 0);
assert_eq!(matches[num_lines - 1].line, num_lines - 1);
for (i, m) in matches.iter().enumerate() {
assert_eq!(m.line, i);
}
}
#[test]
fn test_find_matches_limit() {
let mut content = String::new();
for _ in 0..11_000 {
content.push_str("foo\n");
}
let buffer = TextBuffer::new(&content);
let matches = find_matches(&buffer, "foo", false, Some(MAX_MATCHES));
assert_eq!(matches.len(), MAX_MATCHES);
}
#[test]
fn test_get_visible_match_range() {
let matches = vec![
SearchMatch { line: 1, col: 0 },
SearchMatch { line: 2, col: 0 },
SearchMatch { line: 5, col: 0 },
SearchMatch { line: 5, col: 5 },
SearchMatch { line: 10, col: 0 },
];
assert_eq!(get_visible_match_range(&matches, 0, 15), 0..5);
assert_eq!(get_visible_match_range(&matches, 0, 0), 0..0);
assert_eq!(get_visible_match_range(&matches, 11, 20), 5..5);
assert_eq!(get_visible_match_range(&matches, 3, 4), 2..2);
assert_eq!(get_visible_match_range(&matches, 2, 10), 1..5);
assert_eq!(get_visible_match_range(&matches, 0, 4), 0..2);
assert_eq!(get_visible_match_range(&matches, 2, 5), 1..4);
assert_eq!(get_visible_match_range(&matches, 5, 5), 2..4);
}
#[test]
fn test_get_visible_match_range_empty() {
let matches = vec![];
assert_eq!(get_visible_match_range(&matches, 0, 100), 0..0);
}
}