maybe_regex/
lib.rs

1use crate::utils::{remove_first_n_chars, remove_last_n_chars};
2use lazy_static::lazy_static;
3use log::error;
4use regex::{Captures, Regex, Replacer};
5use std::cmp::Ordering;
6
7mod utils;
8
9lazy_static! {
10    // Simplistic check to see if a string is likely a regex.
11    // TODO: is there a way to make this actually correct?
12    static ref REGEX_REGEX: Regex = Regex::new(r"[\\b\$\^\[\]\+\*\.]").unwrap();
13}
14
15#[derive(Debug, Clone)]
16pub struct MaybeRegex {
17    data: TagWrapperData,
18    original: String,
19    pub is_negative: bool,
20    case_sensitive: bool,
21}
22
23impl PartialEq for MaybeRegex {
24    fn eq(&self, other: &Self) -> bool {
25        self.original == other.original && self.is_negative == other.is_negative
26    }
27}
28
29impl PartialOrd for MaybeRegex {
30    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
31        (&self.original, self.is_negative).partial_cmp(&(&other.original, other.is_negative))
32    }
33}
34
35#[derive(Debug, Clone)]
36pub enum TagWrapperData {
37    Raw(String),
38    Regex(Regex),
39}
40
41impl MaybeRegex {
42    pub fn new<S: AsRef<str>>(s: S) -> Self {
43        Self::from(s)
44    }
45
46    pub fn from<S: AsRef<str>>(s: S) -> Self {
47        let s = s.as_ref();
48        let (s, is_negative) = if s.starts_with("-") {
49            (remove_first_n_chars(s, 1), true)
50        } else if s.ends_with("-") {
51            (remove_last_n_chars(s, 1), true)
52        } else {
53            (s.into(), false)
54        };
55
56        match get_regex(&s) {
57            Some(regex) => Self {
58                data: TagWrapperData::Regex(regex),
59                original: s,
60                is_negative,
61                case_sensitive: false,
62            },
63            None => Self {
64                data: TagWrapperData::Raw(s.clone()),
65                original: s,
66                is_negative,
67                case_sensitive: false,
68            },
69        }
70    }
71
72    pub fn as_case_sensitive(mut self) -> Self {
73        self.case_sensitive = true;
74        self
75    }
76
77    pub fn is_regex(&self) -> bool {
78        match &self.data {
79            TagWrapperData::Raw(_) => false,
80            TagWrapperData::Regex(_) => true,
81        }
82    }
83
84    pub fn matches<S: AsRef<str>>(&self, haystack: S) -> bool {
85        let matches = self.is_contained_within(haystack);
86        if self.is_negative {
87            return !matches;
88        }
89        matches
90    }
91
92    // You likely want matches, which considers whether the input is "negative" or not.
93    // This ignores that and just returns whether the needle is found inside the haystack.
94    pub fn is_contained_within<S: AsRef<str>>(&self, haystack: S) -> bool {
95        let haystack = if self.case_sensitive {
96            haystack.as_ref()
97        } else {
98            &haystack.as_ref().to_lowercase()
99        };
100
101        match &self.data {
102            TagWrapperData::Raw(value) => haystack.contains(value),
103            TagWrapperData::Regex(regex) => regex.is_match(haystack),
104        }
105    }
106
107    pub fn replace(&self, str: String, to_string: impl Fn(&str) -> String + 'static) -> String {
108        let mut output = str;
109        match &self.data {
110            TagWrapperData::Raw(value) => {
111                let replacement = to_string(value);
112                output = output.replace(value, &replacement);
113            }
114            TagWrapperData::Regex(regex) => {
115                let highlighter = Highlighter {
116                    to_string_cb: Box::new(to_string),
117                };
118
119                // TODO: Silly hack since replace_all doesn't seem to span multiple lines
120                output = output.replace("\n", "abcdefg");
121                output = regex.replace_all(&output, highlighter).to_string();
122                output = output.replace("abcdefg", "\n");
123            }
124        };
125        output
126    }
127
128    pub fn to_str(&self) -> &str {
129        self.original.as_str()
130    }
131
132    pub fn to_string(&self) -> String {
133        self.original.clone()
134    }
135
136    pub fn match_indices<S: AsRef<str>>(&self, other: S) -> Vec<(usize, usize)> {
137        let other = if self.case_sensitive {
138            other.as_ref()
139        } else {
140            &other.as_ref().to_lowercase()
141        };
142
143        match &self.data {
144            TagWrapperData::Raw(value) => other
145                .match_indices(value)
146                .map(|(index, _)| (index, value.len()))
147                .collect(),
148            TagWrapperData::Regex(regex) => regex
149                .find_iter(other)
150                .map(|some_match| (some_match.start(), some_match.len()))
151                .collect(),
152        }
153    }
154
155    pub fn matches_exactly<S: AsRef<str>>(&self, other: S) -> bool {
156        let other = if self.case_sensitive {
157            other.as_ref()
158        } else {
159            &other.as_ref().to_lowercase()
160        };
161
162        match &self.data {
163            TagWrapperData::Raw(value) => other == *value,
164            TagWrapperData::Regex(regex) => {
165                if let Some(found) = regex.find(other) {
166                    return found.len() == other.len();
167                }
168                false
169            }
170        }
171    }
172
173    pub fn starts_with<S: AsRef<str>>(&self, s: S) -> bool {
174        let s = if self.case_sensitive {
175            s.as_ref()
176        } else {
177            &s.as_ref().to_lowercase()
178        };
179
180        match &self.data {
181            TagWrapperData::Raw(value) => value.starts_with(s),
182            TagWrapperData::Regex(regex) => {
183                if let Some(found) = regex.find(s) {
184                    return found.start() == 0;
185                }
186                false
187            }
188        }
189    }
190}
191
192fn get_regex(s: &str) -> Option<Regex> {
193    if REGEX_REGEX.is_match(s) {
194        match Regex::new(s) {
195            Ok(regex) => {
196                return Some(regex);
197            }
198            Err(_e) => {
199                error!("Bad regex: {s}");
200            }
201        }
202    }
203    None
204}
205
206struct Highlighter {
207    to_string_cb: Box<dyn Fn(&str) -> String>,
208}
209
210impl Replacer for Highlighter {
211    fn replace_append(&mut self, caps: &Captures<'_>, dst: &mut String) {
212        let temp = caps.get(0).map_or("", |m| m.as_str()).to_string();
213        let rv = (*self.to_string_cb)(&temp);
214        dst.push_str(&rv);
215    }
216}
217
218#[cfg(test)]
219mod test {
220    use super::*;
221
222    #[test]
223    fn detects_regexes() {
224        assert!(MaybeRegex::new("This is a regex.*").is_regex());
225        assert!(MaybeRegex::new(".*This is a regex").is_regex());
226        assert!(MaybeRegex::new(".This is a regex").is_regex());
227        assert!(MaybeRegex::new("This is a regex [0-9]").is_regex());
228    }
229
230    #[test]
231    fn detects_non_regexes() {
232        assert!(!MaybeRegex::new("This is not a regex").is_regex());
233        assert!(!MaybeRegex::new("This is not a regex?").is_regex());
234        assert!(!MaybeRegex::new("This is not a regex [").is_regex());
235        assert!(!MaybeRegex::new("This is not a regex [0-9").is_regex());
236    }
237
238    #[test]
239    fn contains_works() {
240        assert!(!MaybeRegex::new("z").is_contained_within("Hello"));
241        assert!(!MaybeRegex::new("e$").is_contained_within("Hello"));
242
243        assert!(MaybeRegex::new("e").is_contained_within("Hello"));
244        assert!(MaybeRegex::new("o$").is_contained_within("Hello"));
245    }
246
247    #[test]
248    fn negative_works() {
249        assert!(MaybeRegex::new("-e").is_contained_within("Hello"));
250        assert!(!MaybeRegex::new("-e").matches("Hello"));
251
252        assert!(MaybeRegex::new("-o$").is_contained_within("Hello"));
253        assert!(!MaybeRegex::new("-o$").matches("Hello"));
254    }
255
256    #[test]
257    fn all_string_types_work() {
258        assert!(MaybeRegex::new("e").is_contained_within("Hello"));
259        assert!(MaybeRegex::new(String::from("e")).is_contained_within("Hello"));
260        assert!(MaybeRegex::new(&String::from("e")).is_contained_within("Hello"));
261    }
262}