use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum CaseMode {
Sensitive,
Ignore,
#[default]
Smart,
}
impl CaseMode {
#[must_use]
pub fn ignores_case(self, raw: &str) -> bool {
match self {
Self::Sensitive => false,
Self::Ignore => true,
Self::Smart => !raw.chars().any(char::is_uppercase),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PatternError {
#[error("empty search pattern")]
Empty,
#[error("invalid pattern: {0}")]
Invalid(String),
}
#[derive(Debug, Clone)]
pub struct SearchPattern {
raw: String,
case: CaseMode,
regex: regex::Regex,
}
impl SearchPattern {
pub fn compile(raw: &str, case: CaseMode) -> Result<Self, PatternError> {
if raw.is_empty() {
return Err(PatternError::Empty);
}
let regex = regex::RegexBuilder::new(raw)
.case_insensitive(case.ignores_case(raw))
.dot_matches_new_line(false)
.build()
.map_err(|e| PatternError::Invalid(e.to_string()))?;
Ok(Self { raw: raw.to_string(), case, regex })
}
pub fn literal(raw: &str, case: CaseMode) -> Result<Self, PatternError> {
if raw.is_empty() {
return Err(PatternError::Empty);
}
Self::compile(®ex::escape(raw), case)
}
pub fn whole_word(raw: &str, case: CaseMode) -> Result<Self, PatternError> {
if raw.is_empty() {
return Err(PatternError::Empty);
}
Self::compile(&format_word_boundary(®ex::escape(raw)), case)
}
#[must_use]
pub fn raw(&self) -> &str {
&self.raw
}
#[must_use]
pub const fn case(&self) -> CaseMode {
self.case
}
#[must_use]
pub fn ignores_case(&self) -> bool {
self.case.ignores_case(&self.raw)
}
pub(crate) const fn regex(&self) -> ®ex::Regex {
&self.regex
}
}
fn format_word_boundary(escaped: &str) -> String {
let mut s = String::with_capacity(escaped.len() + 4);
s.push_str("\\b");
s.push_str(escaped);
s.push_str("\\b");
s
}
impl PartialEq for SearchPattern {
fn eq(&self, other: &Self) -> bool {
self.raw == other.raw && self.ignores_case() == other.ignores_case()
}
}
impl Eq for SearchPattern {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_pattern_is_rejected_not_silently_accepted() {
assert_eq!(SearchPattern::compile("", CaseMode::Smart), Err(PatternError::Empty));
assert_eq!(SearchPattern::literal("", CaseMode::Smart), Err(PatternError::Empty));
assert_eq!(SearchPattern::whole_word("", CaseMode::Smart), Err(PatternError::Empty));
}
#[test]
fn invalid_regex_cannot_become_a_pattern() {
let e = SearchPattern::compile("a[b", CaseMode::Smart).unwrap_err();
assert!(matches!(e, PatternError::Invalid(_)), "got {e:?}");
}
#[test]
fn smartcase_is_insensitive_until_you_type_a_capital() {
assert!(CaseMode::Smart.ignores_case("foo"));
assert!(!CaseMode::Smart.ignores_case("Foo"));
assert!(!CaseMode::Smart.ignores_case("fooBar"));
}
#[test]
fn explicit_case_modes_ignore_the_pattern_text() {
assert!(!CaseMode::Sensitive.ignores_case("foo"));
assert!(CaseMode::Ignore.ignores_case("FOO"));
}
#[test]
fn literal_escapes_metacharacters() {
let p = SearchPattern::literal("a.c", CaseMode::Sensitive).unwrap();
assert!(p.regex().is_match("a.c"));
assert!(!p.regex().is_match("abc"));
}
#[test]
fn whole_word_does_not_match_inside_a_longer_word() {
let p = SearchPattern::whole_word("foo", CaseMode::Sensitive).unwrap();
assert!(p.regex().is_match("a foo b"));
assert!(!p.regex().is_match("foobar"));
assert!(!p.regex().is_match("barfoo"));
}
#[test]
fn whole_word_keeps_metacharacters_literal() {
let p = SearchPattern::whole_word("a.c", CaseMode::Sensitive).unwrap();
assert!(p.regex().is_match("x a.c y"));
assert!(!p.regex().is_match("x abc y"));
}
#[test]
fn dot_never_matches_across_a_line_boundary() {
let p = SearchPattern::compile("a.b", CaseMode::Sensitive).unwrap();
assert!(!p.regex().is_match("a\nb"));
}
#[test]
fn raw_round_trips_for_the_minibuffer() {
let p = SearchPattern::compile("Foo.*bar", CaseMode::Smart).unwrap();
assert_eq!(p.raw(), "Foo.*bar");
assert!(!p.ignores_case(), "capital F should force sensitivity");
}
}