Skip to main content

aft/
pattern_compile.rs

1use regex::bytes::{Regex, RegexBuilder};
2
3const DEFAULT_SIZE_LIMIT_BYTES: usize = 10 * 1024 * 1024;
4
5#[derive(Clone, Debug)]
6pub enum CompiledPattern {
7    Literal(LiteralSearch),
8    Regex {
9        compiled: Regex,
10        raw_pattern: String,
11        case_insensitive: bool,
12    },
13}
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct LiteralSearch {
17    pub needle: Vec<u8>,
18    pub case_insensitive_ascii: bool,
19}
20
21#[derive(Clone, Debug)]
22pub struct CompileOpts {
23    pub literal: bool,
24    pub case_insensitive: bool,
25    pub multi_line: bool,
26    pub size_limit_bytes: usize,
27}
28
29impl Default for CompileOpts {
30    fn default() -> Self {
31        Self {
32            literal: false,
33            case_insensitive: false,
34            multi_line: true,
35            size_limit_bytes: DEFAULT_SIZE_LIMIT_BYTES,
36        }
37    }
38}
39
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub enum CompileResult {
42    Ok(CompiledPattern),
43    InvalidPattern { message: String, pattern: String },
44    UnsupportedSyntax { feature: String, pattern: String },
45}
46
47impl PartialEq for CompiledPattern {
48    fn eq(&self, other: &Self) -> bool {
49        match (self, other) {
50            (CompiledPattern::Literal(left), CompiledPattern::Literal(right)) => left == right,
51            (
52                CompiledPattern::Regex {
53                    raw_pattern: left_pattern,
54                    case_insensitive: left_case,
55                    ..
56                },
57                CompiledPattern::Regex {
58                    raw_pattern: right_pattern,
59                    case_insensitive: right_case,
60                    ..
61                },
62            ) => left_pattern == right_pattern && left_case == right_case,
63            _ => false,
64        }
65    }
66}
67
68impl Eq for CompiledPattern {}
69
70impl CompiledPattern {
71    pub fn is_literal(&self) -> bool {
72        matches!(self, CompiledPattern::Literal(_))
73    }
74
75    pub fn case_insensitive(&self) -> bool {
76        match self {
77            CompiledPattern::Literal(literal) => literal.case_insensitive_ascii,
78            CompiledPattern::Regex {
79                case_insensitive, ..
80            } => *case_insensitive,
81        }
82    }
83
84    pub fn raw_pattern_for_trigrams(&self) -> String {
85        match self {
86            CompiledPattern::Literal(literal) => {
87                String::from_utf8_lossy(&literal.needle).into_owned()
88            }
89            CompiledPattern::Regex { raw_pattern, .. } => raw_pattern.clone(),
90        }
91    }
92
93    pub fn ripgrep_pattern(&self) -> String {
94        match self {
95            CompiledPattern::Literal(literal) => {
96                String::from_utf8_lossy(&literal.needle).into_owned()
97            }
98            CompiledPattern::Regex { raw_pattern, .. } => raw_pattern.clone(),
99        }
100    }
101}
102
103pub fn compile(pattern: &str, opts: CompileOpts) -> CompileResult {
104    if pattern.len() > opts.size_limit_bytes {
105        return CompileResult::InvalidPattern {
106            message: format!(
107                "invalid regex: pattern exceeds size limit of {} bytes",
108                opts.size_limit_bytes
109            ),
110            pattern: pattern.to_string(),
111        };
112    }
113
114    if !opts.literal {
115        if let Some(feature) = detect_unsupported_features(pattern) {
116            return CompileResult::UnsupportedSyntax {
117                feature,
118                pattern: pattern.to_string(),
119            };
120        }
121    }
122
123    let has_regex_meta = has_regex_metachar(pattern);
124    let ascii_safe_literal = opts.case_insensitive && pattern.is_ascii();
125    if opts.literal || (!has_regex_meta && (!opts.case_insensitive || ascii_safe_literal)) {
126        if !opts.case_insensitive || pattern.is_ascii() {
127            let needle = if opts.case_insensitive {
128                pattern
129                    .as_bytes()
130                    .iter()
131                    .map(|byte| byte.to_ascii_lowercase())
132                    .collect()
133            } else {
134                pattern.as_bytes().to_vec()
135            };
136            return CompileResult::Ok(CompiledPattern::Literal(LiteralSearch {
137                needle,
138                case_insensitive_ascii: opts.case_insensitive,
139            }));
140        }
141    }
142
143    let mut regex_pattern = if opts.literal || !has_regex_meta {
144        regex::escape(pattern)
145    } else {
146        pattern.to_string()
147    };
148    let mut builder_case_insensitive = opts.case_insensitive;
149    if opts.case_insensitive && !pattern.is_ascii() {
150        regex_pattern = format!("(?i){regex_pattern}");
151        builder_case_insensitive = false;
152    }
153
154    let mut builder = RegexBuilder::new(&regex_pattern);
155    builder.case_insensitive(builder_case_insensitive);
156    builder.multi_line(opts.multi_line);
157    builder.size_limit(opts.size_limit_bytes);
158
159    match builder.build() {
160        Ok(compiled) => CompileResult::Ok(CompiledPattern::Regex {
161            compiled,
162            raw_pattern: regex_pattern,
163            case_insensitive: opts.case_insensitive,
164        }),
165        Err(error) => CompileResult::InvalidPattern {
166            message: format!("invalid regex: {error}"),
167            pattern: pattern.to_string(),
168        },
169    }
170}
171
172pub fn detect_unsupported_features(pattern: &str) -> Option<String> {
173    if ["(?=", "(?!", "(?<=", "(?<!"]
174        .iter()
175        .any(|token| contains_syntax_token(pattern, token))
176    {
177        return Some("lookaround".to_string());
178    }
179    if contains_syntax_token(pattern, "(?P=") || contains_numeric_backreference(pattern) {
180        return Some("backreference".to_string());
181    }
182    if ["*+", "++", "?+"]
183        .iter()
184        .any(|token| contains_syntax_token(pattern, token))
185    {
186        return Some("possessive quantifier".to_string());
187    }
188    if contains_syntax_token(pattern, "(?>") {
189        return Some("atomic group".to_string());
190    }
191    None
192}
193
194fn contains_syntax_token(pattern: &str, token: &str) -> bool {
195    let bytes = pattern.as_bytes();
196    let token = token.as_bytes();
197    let mut index = 0;
198    let mut class_depth = 0usize;
199
200    while index < bytes.len() {
201        match bytes[index] {
202            b'\\' => index = index.saturating_add(2),
203            b'[' => {
204                class_depth = class_depth.saturating_add(1);
205                index += 1;
206            }
207            b']' if class_depth > 0 => {
208                class_depth -= 1;
209                index += 1;
210            }
211            _ if class_depth == 0 && bytes[index..].starts_with(token) => return true,
212            _ => index += 1,
213        }
214    }
215
216    false
217}
218
219fn has_regex_metachar(pattern: &str) -> bool {
220    pattern.chars().any(|c| {
221        matches!(
222            c,
223            '.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
224        )
225    })
226}
227
228fn contains_numeric_backreference(pattern: &str) -> bool {
229    let mut escaped = false;
230    for ch in pattern.chars() {
231        if escaped {
232            if ('1'..='9').contains(&ch) {
233                return true;
234            }
235            escaped = false;
236            continue;
237        }
238        escaped = ch == '\\';
239    }
240    false
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn assert_literal(pattern: &str, case_insensitive: bool, expected: &[u8]) {
248        let result = compile(
249            pattern,
250            CompileOpts {
251                case_insensitive,
252                ..CompileOpts::default()
253            },
254        );
255        match result {
256            CompileResult::Ok(CompiledPattern::Literal(literal)) => {
257                assert_eq!(literal.needle, expected);
258                assert_eq!(literal.case_insensitive_ascii, case_insensitive);
259            }
260            other => panic!("expected literal, got {other:?}"),
261        }
262    }
263
264    #[test]
265    fn literal_pattern_without_metachars_uses_fast_path() {
266        assert_literal("needle", false, b"needle");
267    }
268
269    #[test]
270    fn ascii_case_insensitive_literal_uses_lowercase_fast_path() {
271        assert_literal("Needle", true, b"needle");
272    }
273
274    #[test]
275    fn non_ascii_case_insensitive_literal_forces_regex_with_inline_flag() {
276        let result = compile(
277            "Äbc",
278            CompileOpts {
279                case_insensitive: true,
280                ..CompileOpts::default()
281            },
282        );
283        match result {
284            CompileResult::Ok(CompiledPattern::Regex {
285                raw_pattern,
286                case_insensitive,
287                ..
288            }) => {
289                assert!(raw_pattern.starts_with("(?i)"));
290                assert!(case_insensitive);
291            }
292            other => panic!("expected regex, got {other:?}"),
293        }
294    }
295
296    #[test]
297    fn regex_pattern_retains_raw_pattern_and_compiles_bytes_regex() {
298        let result = compile("foo.*bar", CompileOpts::default());
299        match result {
300            CompileResult::Ok(CompiledPattern::Regex {
301                compiled,
302                raw_pattern,
303                ..
304            }) => {
305                assert_eq!(raw_pattern, "foo.*bar");
306                assert!(compiled.is_match(b"foo middle bar"));
307            }
308            other => panic!("expected regex, got {other:?}"),
309        }
310    }
311
312    #[test]
313    fn invalid_pattern_surfaces_compile_error() {
314        let result = compile("[", CompileOpts::default());
315        assert!(matches!(result, CompileResult::InvalidPattern { .. }));
316    }
317
318    #[test]
319    fn pattern_exceeding_size_limit_is_invalid() {
320        let result = compile(
321            "abcd",
322            CompileOpts {
323                size_limit_bytes: 3,
324                ..CompileOpts::default()
325            },
326        );
327        assert!(matches!(result, CompileResult::InvalidPattern { .. }));
328    }
329
330    #[test]
331    fn unsupported_syntax_is_detected_before_compile() {
332        for pattern in [
333            "(?=foo)",
334            "(?!foo)",
335            "(?<=foo)",
336            "(?<!foo)",
337            "(?P=name)",
338            r"\1",
339            "foo*+",
340            "(?>foo)",
341        ] {
342            assert!(
343                matches!(
344                    compile(pattern, CompileOpts::default()),
345                    CompileResult::UnsupportedSyntax { .. }
346                ),
347                "{pattern}"
348            );
349        }
350    }
351
352    #[test]
353    fn valid_regex_tokens_are_not_misclassified_as_unsupported() {
354        for (pattern, haystack) in [
355            (r"\*+", b"***".as_slice()),
356            (r"\(?=", b"(=".as_slice()),
357            (r"\(?P=", b"(P=".as_slice()),
358            (r"\(?>", b"(>".as_slice()),
359            (r"[(?=]+", b"(?=".as_slice()),
360            (r"[*+]+", b"*+".as_slice()),
361            (r"[(?>]+", b"(?>".as_slice()),
362            (r"[(?P=]+", b"(?P=".as_slice()),
363        ] {
364            match compile(pattern, CompileOpts::default()) {
365                CompileResult::Ok(CompiledPattern::Regex { compiled, .. }) => {
366                    assert!(compiled.is_match(haystack), "{pattern}");
367                }
368                other => panic!("expected valid regex for {pattern}, got {other:?}"),
369            }
370        }
371    }
372
373    #[test]
374    fn forced_literal_honors_regex_characters() {
375        let result = compile(
376            "foo.*bar",
377            CompileOpts {
378                literal: true,
379                ..CompileOpts::default()
380            },
381        );
382        match result {
383            CompileResult::Ok(CompiledPattern::Literal(literal)) => {
384                assert_eq!(literal.needle, b"foo.*bar");
385            }
386            other => panic!("expected literal, got {other:?}"),
387        }
388    }
389}