pub mod matcher;
use crate::editor::document::Document;
use crate::editor::selection::Range;
pub use matcher::{LineMatch, Matcher};
#[derive(Debug, Default)]
pub struct Search {
pub query: String,
pub regex: bool,
pub case_sensitive: Option<bool>,
pub forward: bool,
matcher: Option<Matcher>,
error: Option<String>,
origin: usize,
}
impl Search {
pub fn begin(&mut self, origin: usize, forward: bool) {
self.query.clear();
self.matcher = None;
self.error = None;
self.origin = origin;
self.forward = forward;
}
#[must_use]
pub const fn origin(&self) -> usize {
self.origin
}
pub fn set_query(&mut self, query: String) {
self.query = query;
self.compile();
}
pub fn push(&mut self, ch: char) {
self.query.push(ch);
self.compile();
}
pub fn pop(&mut self) {
self.query.pop();
self.compile();
}
#[must_use]
pub fn error(&self) -> Option<&str> {
self.error.as_deref()
}
#[must_use]
pub const fn is_active(&self) -> bool {
self.matcher.is_some()
}
#[must_use]
pub fn matches_in_line(&self, line: &str) -> Vec<LineMatch> {
self.matcher
.as_ref()
.map(|matcher| matcher.find_all(line))
.unwrap_or_default()
}
#[must_use]
pub fn find(&self, document: &Document, from: usize, forward: bool) -> Option<Range> {
let matcher = self.matcher.as_ref()?;
let lines = document.len_lines();
let start_line = document.char_to_pos(from).line;
for offset in 0..=lines {
let line = if forward {
(start_line + offset) % lines
} else {
(start_line + lines - offset % lines) % lines
};
let text = document.line_string(line);
let line_start = document.line_start(line);
let found = matcher.find_all(&text);
let hit = if forward {
found
.iter()
.find(|m| offset > 0 || line_start + m.start > from)
} else {
found
.iter()
.rev()
.find(|m| offset > 0 || line_start + m.start < from)
};
if let Some(hit) = hit {
return Some(Range {
start: line_start + hit.start,
end: line_start + hit.end,
});
}
}
None
}
#[must_use]
pub fn count(&self, document: &Document, cap: usize) -> usize {
let Some(matcher) = self.matcher.as_ref() else {
return 0;
};
let mut total = 0;
for line in 0..document.len_lines() {
total += matcher.find_all(&document.line_string(line)).len();
if total >= cap {
return cap;
}
}
total
}
pub fn replace_in(
&self,
document: &mut Document,
lines: std::ops::Range<usize>,
replacement: &str,
all: bool,
) -> usize {
let Some(matcher) = self.matcher.as_ref() else {
return 0;
};
let mut total = 0;
for line in lines.rev() {
if line >= document.len_lines() {
continue;
}
let text = document.line_string(line);
let (replaced, count) = matcher.replace(&text, replacement, all);
if count == 0 {
continue;
}
let start = document.line_start(line);
document.remove(start, start + text.chars().count());
document.insert(start, &replaced);
total += count;
}
total
}
fn compile(&mut self) {
if self.query.is_empty() {
self.matcher = None;
self.error = None;
return;
}
match Matcher::new(&self.query, self.regex, self.case_sensitive) {
Ok(matcher) => {
self.matcher = Some(matcher);
self.error = None;
}
Err(message) => {
self.matcher = None;
self.error = Some(message);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn search(query: &str) -> Search {
let mut search = Search::default();
search.set_query(query.to_string());
search
}
fn doc(text: &str) -> Document {
Document::from_text(text, None)
}
#[test]
fn finds_the_next_match_after_the_caret() {
let document = doc("foo bar\nfoo baz");
let found = search("foo").find(&document, 0, true).expect("a match");
assert_eq!(found, Range { start: 8, end: 11 });
}
#[test]
fn searching_forward_wraps_around_the_end() {
let document = doc("foo bar\nbaz");
let found = search("foo").find(&document, 9, true).expect("a match");
assert_eq!(found, Range { start: 0, end: 3 });
}
#[test]
fn searching_backward_finds_the_previous_match() {
let document = doc("foo\nbar\nfoo");
let found = search("foo").find(&document, 8, false).expect("a match");
assert_eq!(found, Range { start: 0, end: 3 });
}
#[test]
fn a_missing_pattern_finds_nothing() {
let document = doc("abc");
assert!(search("zzz").find(&document, 0, true).is_none());
}
#[test]
fn an_invalid_regex_reports_an_error_and_matches_nothing() {
let mut search = Search {
regex: true,
..Search::default()
};
search.set_query("(unclosed".to_string());
assert!(search.error().is_some());
assert!(!search.is_active());
}
#[test]
fn counting_stops_at_the_cap() {
let document = doc(&"x\n".repeat(100));
assert_eq!(search("x").count(&document, 10), 10);
}
#[test]
fn replacement_rewrites_the_requested_lines_only() {
let mut document = doc("a\na\na");
let replaced = search("a").replace_in(&mut document, 0..2, "b", true);
assert_eq!(replaced, 2);
assert_eq!(document.text().to_string(), "b\nb\na");
}
}