harn_kernel/pure/
regex.rs1use std::cell::RefCell;
2use std::collections::{BTreeMap, HashMap};
3use std::rc::Rc;
4
5const REGEX_CACHE_LIMIT: usize = 128;
6pub const MAX_REGEX_PATTERN_BYTES: usize = 64 * 1024;
7
8thread_local! {
9 static REGEX_CACHE: RefCell<HashMap<String, Rc<regex::Regex>>> = RefCell::new(HashMap::new());
10 static LAST_REGEX: RefCell<Option<(String, String, Rc<regex::Regex>)>> =
11 const { RefCell::new(None) };
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct RegexCapture {
16 pub full_match: String,
17 pub groups: Vec<Option<String>>,
18 pub start: usize,
19 pub end: usize,
20 pub line: usize,
21 pub named: BTreeMap<String, String>,
22}
23
24fn compiled(pattern: &str, flags: &str) -> Result<Rc<regex::Regex>, String> {
25 if pattern.len() > MAX_REGEX_PATTERN_BYTES {
26 return Err(format!(
27 "regex pattern exceeds the {MAX_REGEX_PATTERN_BYTES}-byte limit"
28 ));
29 }
30 if let Some(regex) = LAST_REGEX.with(|slot| {
31 slot.borrow()
32 .as_ref()
33 .filter(|(cached_pattern, cached_flags, _)| {
34 cached_pattern == pattern && cached_flags == flags
35 })
36 .map(|(_, _, regex)| Rc::clone(regex))
37 }) {
38 return Ok(regex);
39 }
40
41 let regex = REGEX_CACHE.with(|cache| -> Result<Rc<regex::Regex>, String> {
42 let key = format!("{flags}\0{pattern}");
43 let mut cache = cache.borrow_mut();
44 if let Some(regex) = cache.get(&key) {
45 return Ok(Rc::clone(regex));
46 }
47 let mut builder = regex::RegexBuilder::new(pattern);
48 for flag in flags.chars() {
49 match flag {
50 'i' => builder.case_insensitive(true),
51 'm' => builder.multi_line(true),
52 's' => builder.dot_matches_new_line(true),
53 'x' => builder.ignore_whitespace(true),
54 _ => {
55 return Err(format!(
56 "unsupported regex flag '{flag}', expected one of i/m/s/x"
57 ));
58 }
59 };
60 }
61 let regex = Rc::new(builder.build().map_err(|error| error.to_string())?);
62 if cache.len() >= REGEX_CACHE_LIMIT {
63 cache.clear();
64 }
65 cache.insert(key, Rc::clone(®ex));
66 Ok(regex)
67 })?;
68
69 LAST_REGEX.with(|slot| {
70 *slot.borrow_mut() = Some((pattern.to_string(), flags.to_string(), Rc::clone(®ex)));
71 });
72 Ok(regex)
73}
74
75pub fn regex_matches(pattern: &str, text: &str, flags: &str) -> Result<Vec<String>, String> {
76 Ok(compiled(pattern, flags)?
77 .find_iter(text)
78 .map(|matched| matched.as_str().to_string())
79 .collect())
80}
81
82pub fn regex_replace(
83 pattern: &str,
84 replacement: &str,
85 text: &str,
86 flags: &str,
87) -> Result<String, String> {
88 Ok(compiled(pattern, flags)?
89 .replace_all(text, replacement)
90 .into_owned())
91}
92
93pub fn regex_split(pattern: &str, text: &str, flags: &str) -> Result<Vec<String>, String> {
94 Ok(compiled(pattern, flags)?
95 .split(text)
96 .map(str::to_string)
97 .collect())
98}
99
100pub fn regex_captures(pattern: &str, text: &str, flags: &str) -> Result<Vec<RegexCapture>, String> {
101 let regex = compiled(pattern, flags)?;
102 let names = regex
103 .capture_names()
104 .flatten()
105 .map(str::to_string)
106 .collect::<Vec<_>>();
107 let mut scanned_byte = 0;
108 let mut chars_before = 0;
109 let mut newlines_before = 0;
110 let mut results = Vec::new();
111
112 for captures in regex.captures_iter(text) {
113 let whole = captures
114 .get(0)
115 .expect("regex capture always includes the full match");
116 #[expect(
117 clippy::string_slice,
118 reason = "regex match bounds are char boundaries"
119 )]
120 let gap = &text[scanned_byte..whole.start()];
121 chars_before += gap.chars().count();
122 newlines_before += gap.bytes().filter(|byte| *byte == b'\n').count();
123 let start = chars_before;
124 let line = newlines_before + 1;
125 let matched = whole.as_str();
126 chars_before += matched.chars().count();
127 newlines_before += matched.bytes().filter(|byte| *byte == b'\n').count();
128 scanned_byte = whole.end();
129
130 let groups = (1..captures.len())
131 .map(|index| captures.get(index).map(|value| value.as_str().to_string()))
132 .collect();
133 let named = names
134 .iter()
135 .filter_map(|name| {
136 captures
137 .name(name)
138 .map(|value| (name.clone(), value.as_str().to_string()))
139 })
140 .collect();
141 results.push(RegexCapture {
142 full_match: matched.to_string(),
143 groups,
144 start,
145 end: chars_before,
146 line,
147 named,
148 });
149 }
150 Ok(results)
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn captures_use_character_offsets_names_and_lines() {
159 let captures = regex_captures(r"(?m)^(?<word>\w+)-(\d+)$", "λ-1\nHarn-42", "").unwrap();
160 assert_eq!(captures.len(), 2);
161 assert_eq!(captures[0].start, 0);
162 assert_eq!(captures[0].end, 3);
163 assert_eq!(captures[1].line, 2);
164 assert_eq!(captures[1].named["word"], "Harn");
165 assert_eq!(captures[1].groups[1].as_deref(), Some("42"));
166 }
167
168 #[test]
169 fn flags_and_pattern_size_are_bounded() {
170 assert!(regex_matches("harn", "HARN", "i").unwrap().len() == 1);
171 assert!(regex_matches("harn", "harn", "q").is_err());
172 assert!(regex_matches(&"x".repeat(MAX_REGEX_PATTERN_BYTES + 1), "x", "").is_err());
173 }
174}