guard_core_engine/patterns/
pyregex.rs1use regex::Regex;
23use std::collections::HashMap;
24use std::sync::{LazyLock, Mutex};
25
26static COMPILE_CACHE: LazyLock<Mutex<HashMap<(String, bool), Regex>>> =
33 LazyLock::new(|| Mutex::new(HashMap::new()));
34
35pub struct PyRegex {
36 pub source: String,
38 re: Regex,
39}
40
41fn translate(source: &str) -> String {
42 let mut out = String::with_capacity(source.len() + 8);
43 let mut chars = source.chars().peekable();
44 let mut in_class = false;
45 while let Some(ch) = chars.next() {
46 if ch == '\\' {
47 out.push(ch);
48 if let Some(&next) = chars.peek() {
49 if next == 'Z' {
50 out.pop();
51 out.push_str("\\z");
52 } else {
53 out.push(next);
54 }
55 chars.next();
56 }
57 continue;
58 }
59 match ch {
60 '[' if !in_class => {
61 in_class = true;
62 out.push(ch);
63 }
64 ']' if in_class => {
65 in_class = false;
66 out.push(ch);
67 }
68 '$' if !in_class => {
69 out.push_str("(?:\\n?\\z)");
71 }
72 _ => out.push(ch),
73 }
74 }
75 out
76}
77
78impl PyRegex {
79 pub fn compile(source: &str, ignore_case: bool) -> Result<Self, String> {
80 let effective_case = if source.contains("(?-i:") {
81 false
85 } else {
86 ignore_case || source.starts_with("(?i)")
87 };
88 let cache_key = (source.to_owned(), effective_case);
89 if let Ok(cache) = COMPILE_CACHE.lock()
90 && let Some(re) = cache.get(&cache_key)
91 {
92 return Ok(Self {
93 source: source.to_owned(),
94 re: re.clone(),
95 });
96 }
97 let translated = translate(source);
98 let mut pattern = String::with_capacity(translated.len() + 8);
99 if effective_case && !translated.starts_with("(?i)") {
100 pattern.push_str("(?i)");
101 }
102 pattern.push_str(&translated);
103 let re = Regex::new(&pattern).map_err(|e| format!("{source}: {e}"))?;
104 #[cfg(not(coverage))] if let Ok(mut cache) = COMPILE_CACHE.lock() {
107 cache.insert(cache_key, re.clone());
108 }
109 #[cfg(coverage)]
110 COMPILE_CACHE
111 .lock()
112 .expect("the compile cache is never poisoned")
113 .insert(cache_key, re.clone());
114 Ok(Self {
115 source: source.to_owned(),
116 re,
117 })
118 }
119
120 #[must_use]
121 pub const fn re(&self) -> &Regex {
122 &self.re
123 }
124}
125
126#[must_use]
128pub fn cp_index(haystack: &str, byte_idx: usize) -> usize {
129 haystack[..byte_idx].chars().count()
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct Candidate {
135 pub start: usize,
136 pub end: usize,
137}
138
139impl Candidate {
140 #[must_use]
141 pub const fn new(start: usize, end: usize) -> Self {
142 Self { start, end }
143 }
144
145 #[must_use]
146 pub fn text<'a>(&self, haystack: &'a str) -> &'a str {
147 &haystack[self.start..self.end]
148 }
149}
150
151#[must_use]
152pub fn find_all(re: &Regex, haystack: &str) -> Vec<Candidate> {
153 re.find_iter(haystack)
154 .map(|m| Candidate::new(m.start(), m.end()))
155 .collect()
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn translates_absolute_end_anchor() {
164 let re = PyRegex::compile(r"\A[^\n]*x\s*\Z", false).unwrap();
165 assert!(re.re().is_match("a x"));
166 assert!(!re.re().is_match("a\nx"));
167 }
168
169 #[test]
170 fn translates_python_dollar() {
171 let re = PyRegex::compile(r"(?:/|\s|$)", false).unwrap();
172 assert!(re.re().is_match("/"));
173 assert!(re.re().is_match("x\n"));
174 }
175
176 #[test]
177 fn scoped_case_sensitivity() {
178 let re = PyRegex::compile("(?i)(?-i:rO0AB)", false).unwrap();
179 assert!(re.re().is_match("rO0AB"));
180 assert!(!re.re().is_match("ro0ab"));
181 }
182}
183
184#[cfg(test)]
185mod coverage_tests {
186 use super::*;
187
188 #[test]
189 fn find_all_maps_every_match_to_a_candidate() {
190 let re = PyRegex::compile(r"ab", false).unwrap();
191 let ms = find_all(re.re(), "abxab");
192 assert_eq!(ms.len(), 2);
193 assert_eq!(ms[0].start, 0);
194 assert_eq!(ms[1].start, 3);
195 }
196
197 #[test]
198 fn a_trailing_escape_passes_through_the_translator() {
199 assert_eq!(translate("x\\"), "x\\");
201 assert_eq!(translate("\\"), "\\");
202 assert!(PyRegex::compile("x\\", false).is_err());
205 }
206}