1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
#![allow(clippy::needless_pass_by_value)]
use std::borrow::Cow;
use std::cell::RefCell;
use cached::Cached;
use crate::error;
/// Cache mapping a (pattern, case-insensitive, multiline) key to a compiled
/// regex.
type RegexCache = cached::LruCache<(String, bool, bool), fancy_regex::Regex>;
thread_local! {
// Wrapped in `Option` so that if cache construction ever fails we gracefully
// degrade to compiling regexes uncached rather than panicking. (With a fixed
// positive `max_size` this always succeeds, but `build()` is fallible.)
static REGEX_CACHE: RefCell<Option<RegexCache>> =
RefCell::new(cached::LruCache::builder().max_size(64).build().ok());
}
/// Represents a piece of a regular expression.
#[derive(Clone, Debug)]
pub(crate) enum RegexPiece {
/// A pattern that should be interpreted as a regular expression.
Pattern(String),
/// A literal string that should be matched exactly.
Literal(String),
}
impl RegexPiece {
fn to_regex_str(&self) -> Cow<'_, str> {
match self {
Self::Pattern(s) => Cow::Borrowed(s.as_str()),
Self::Literal(s) => escape_literal_regex_piece(s.as_str()),
}
}
}
type RegexWord = Vec<RegexPiece>;
/// Encapsulates a regular expression usable in the shell.
#[derive(Clone, Debug)]
pub struct Regex {
pieces: RegexWord,
case_insensitive: bool,
multiline: bool,
}
impl From<RegexWord> for Regex {
fn from(pieces: RegexWord) -> Self {
Self {
pieces,
case_insensitive: false,
multiline: false,
}
}
}
impl Regex {
/// Sets the regular expression's case sensitivity.
///
/// # Arguments
///
/// * `value` - The new case sensitivity value.
pub const fn set_case_insensitive(mut self, value: bool) -> Self {
self.case_insensitive = value;
self
}
/// Enables (or disables) multiline support for this pattern: `.` then also
/// matches newline characters. `^` and `$` always anchor to the whole
/// string, as in POSIX regular expressions, which have no line anchors.
///
/// # Arguments
///
/// * `value` - The new multiline value.
pub const fn set_multiline(mut self, value: bool) -> Self {
self.multiline = value;
self
}
/// Computes if the regular expression matches the given string.
///
/// # Arguments
///
/// * `value` - The string to check for a match.
///
/// # Errors
///
/// * [`error::ErrorKind::InvalidRegexError`] if the assembled pattern does
/// not compile.
/// * [`error::ErrorKind::RegexError`] if matching fails at runtime, for
/// example when a backtracking limit is exceeded.
pub fn matches(&self, value: &str) -> Result<Option<Vec<Option<String>>>, error::Error> {
let regex_pattern: String = self
.pieces
.iter()
.map(|piece| piece.to_regex_str())
.collect();
let re = compile_regex(regex_pattern, self.case_insensitive, self.multiline)?;
Ok(re.captures(value)?.map(|captures| {
captures
.iter()
.map(|c| c.map(|m| m.as_str().to_owned()))
.collect()
}))
}
}
pub(crate) fn compile_regex(
regex_str: String,
case_insensitive: bool,
multiline: bool,
) -> Result<fancy_regex::Regex, error::Error> {
// Move regex_str into the key to avoid cloning on cache-hit path.
let key = (regex_str, case_insensitive, multiline);
let cached_regex = REGEX_CACHE.with(|cache| {
cache
.borrow_mut()
.as_mut()
.and_then(|c| c.cache_get(&key).cloned())
});
if let Some(re) = cached_regex {
return Ok(re);
}
// Handle identified cases where a shell-supported regex isn't supported
// directly by `fancy_regex` -- specifically, adding missing escape
// characters.
let mut regex_str = add_missing_escape_chars_to_regex(key.0.as_str());
// Handle multiline enablement.
if multiline {
// The fancy_regex crate internally seems to have flags that can be used
// to enable multiline support, but they're not exposed via its
// RegexBuilder. We instead just prefix with the right flags.
let updated_str = std::format!("(?s){regex_str}");
regex_str = updated_str.into();
}
let mut builder = fancy_regex::RegexBuilder::new(regex_str.as_ref());
builder.case_insensitive(case_insensitive);
let re = match builder.build() {
Ok(re) => re,
Err(e) => return Err(error::ErrorKind::InvalidRegexError(e, regex_str.to_string()).into()),
};
// Release borrow on key.0 before moving key into cache_set.
drop(regex_str);
REGEX_CACHE.with(|cache| {
if let Some(c) = cache.borrow_mut().as_mut() {
c.cache_set(key, re.clone());
}
});
Ok(re)
}
fn add_missing_escape_chars_to_regex(s: &str) -> Cow<'_, str> {
// We may see a character class with an unescaped '[' (open bracket)
// character. We need to escape that character.
let mut in_escape = false;
let mut in_brackets = false;
// A ']' in the first member position of a bracket expression (immediately
// after the opening '[' or after the leading '^' that negates it) is a
// member of the expression, not its terminator.
let mut at_first_member = false;
let mut insertion_positions = vec![];
let mut peekable = s.char_indices().peekable();
while let Some((byte_offset, c)) = peekable.next() {
let next_is_colon = peekable.peek().is_some_and(|(_, c)| *c == ':');
let was_at_first_member = at_first_member;
at_first_member = false;
match c {
'[' if !in_escape && !in_brackets => {
in_brackets = true;
// A '^' here negates the expression; it isn't a member itself,
// so the first member position is the one after
// it. Any later '^' is an ordinary member.
let _ = peekable.next_if(|(_, c)| *c == '^');
at_first_member = true;
}
'[' if !in_escape && in_brackets && !next_is_colon => {
// Need to escape.
insertion_positions.push(byte_offset);
}
']' if !in_escape && in_brackets && was_at_first_member => {
// `fancy_regex` doesn't implement the rule that this ']' is a
// member; escape it so it's a member there too,
// and so it can be the low end of a range (e.g.
// `[]-a]`).
insertion_positions.push(byte_offset);
}
']' if !in_escape && in_brackets => {
in_brackets = false;
}
_ => (),
}
in_escape = !in_escape && c == '\\';
}
if insertion_positions.is_empty() {
return s.into();
}
let mut updated = s.to_owned();
for pos in insertion_positions.iter().rev() {
updated.insert(*pos, '\\');
}
updated.into()
}
fn escape_literal_regex_piece(s: &str) -> Cow<'_, str> {
let mut result = String::new();
for c in s.chars() {
match c {
c if regex_char_is_special(c) => {
result.push('\\');
result.push(c);
}
c => result.push(c),
}
}
result.into()
}
pub(crate) const fn regex_char_is_special(c: char) -> bool {
matches!(
c,
'\\' | '^' | '$' | '.' | '|' | '?' | '*' | '+' | '(' | ')' | '[' | ']' | '{' | '}'
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_missing_escape_chars_to_regex() {
// Negative cases -- where we don't need to escape.
assert_eq!(add_missing_escape_chars_to_regex("a[b]"), "a[b]");
assert_eq!(add_missing_escape_chars_to_regex(r"a\[b\]"), r"a\[b\]");
assert_eq!(add_missing_escape_chars_to_regex(r"a[b\[]"), r"a[b\[]");
// Positive case -- where we need to escape.
assert_eq!(add_missing_escape_chars_to_regex(r"a[b[]"), r"a[b\[]");
assert_eq!(add_missing_escape_chars_to_regex(r"a[[]"), r"a[\[]");
}
#[test]
fn test_leading_close_bracket_in_bracket_expression() {
// A ']' in the first member position (after any '^') is a member of the
// bracket expression, not its terminator, so the expression is
// still open after it. It's escaped as well, since
// `fancy_regex` doesn't implement that rule; that also makes it
// the low end of a range in `[]-a]`, as it is in a shell pattern.
assert_eq!(add_missing_escape_chars_to_regex("[]]"), r"[\]]");
assert_eq!(add_missing_escape_chars_to_regex("[^]]"), r"[^\]]");
assert_eq!(add_missing_escape_chars_to_regex("[][]"), r"[\]\[]");
assert_eq!(add_missing_escape_chars_to_regex("[^][]"), r"[^\]\[]");
assert_eq!(add_missing_escape_chars_to_regex("[]a[]"), r"[\]a\[]");
assert_eq!(add_missing_escape_chars_to_regex("[]-a]"), r"[\]-a]");
// The rule only applies in the first member position: the ']' in `[a]`
// and the already-escaped one in `[\]]` both close their
// expressions.
assert_eq!(add_missing_escape_chars_to_regex("[a][]x]"), r"[a][\]x]");
assert_eq!(add_missing_escape_chars_to_regex(r"[\]][]x]"), r"[\]][\]x]");
// Only the '^' that negates the expression is skipped over; a later one
// is an ordinary member, so the ']' after it terminates the
// expression.
assert_eq!(add_missing_escape_chars_to_regex("[^^][ab]"), "[^^][ab]");
assert_eq!(add_missing_escape_chars_to_regex("[^^][b[]"), r"[^^][b\[]");
// A '^' outside a bracket expression doesn't open one.
assert_eq!(add_missing_escape_chars_to_regex("^[a[]"), r"^[a\[]");
}
}