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),
}
}
}
fn split_case_override(raw: &str) -> (String, Option<bool>) {
let mut out = String::with_capacity(raw.len());
let mut forced: Option<bool> = None;
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.peek() {
Some('c') if forced.is_none() => {
chars.next();
forced = Some(true);
}
Some('C') if forced.is_none() => {
chars.next();
forced = Some(false);
}
Some(&next) => {
out.push(c);
out.push(next);
chars.next();
}
None => out.push(c),
}
}
(out, forced)
}
#[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,
insensitive: bool,
regex: regex::Regex,
}
impl SearchPattern {
pub fn compile(raw: &str, case: CaseMode) -> Result<Self, PatternError> {
if raw.is_empty() {
return Err(PatternError::Empty);
}
let (body, forced) = split_case_override(raw);
if body.is_empty() {
return Err(PatternError::Empty);
}
let insensitive = forced.map_or_else(|| case.ignores_case(&body), |f| f);
let regex = regex::RegexBuilder::new(&body)
.case_insensitive(insensitive)
.dot_matches_new_line(false)
.build()
.map_err(|e| PatternError::Invalid(e.to_string()))?;
Ok(Self {
raw: raw.to_string(),
case,
insensitive,
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.insensitive
}
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");
}
#[test]
fn case_override_forces_both_directions() {
let p = SearchPattern::compile(r"\cFOO", CaseMode::Smart).expect("compiles");
assert!(p.ignores_case(), "\\c must force insensitive");
let p = SearchPattern::compile(r"\Cfoo", CaseMode::Smart).expect("compiles");
assert!(!p.ignores_case(), "\\C must force sensitive");
}
#[test]
fn a_case_override_beats_an_explicit_mode_too() {
let p = SearchPattern::compile(r"\Cfoo", CaseMode::Ignore).expect("compiles");
assert!(!p.ignores_case());
}
#[test]
fn the_override_is_stripped_before_the_regex_sees_it() {
let p = SearchPattern::compile(r"\cabc", CaseMode::Smart).expect("compiles");
assert!(p.regex().is_match("ABC"), "the body must be `abc`");
}
#[test]
fn an_escaped_backslash_is_not_a_case_override() {
let p = SearchPattern::compile(r"a\\c", CaseMode::Sensitive).expect("compiles");
assert!(
p.regex().is_match(r"a\c"),
"must still match a literal backslash-c"
);
assert!(!p.ignores_case(), "no override was present");
}
#[test]
fn a_pattern_that_is_only_an_override_is_empty() {
assert_eq!(
SearchPattern::compile(r"\c", CaseMode::Smart),
Err(PatternError::Empty),
);
}
#[test]
fn the_override_may_appear_anywhere() {
let p = SearchPattern::compile(r"foo\c", CaseMode::Sensitive).expect("compiles");
assert!(p.ignores_case());
assert!(p.regex().is_match("FOO"));
}
}