polydat_core/library/support/pattern.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pattern promotion: a literal, a `*` glob, or a regex, told apart by
5//! shape and compiled to one anchored regex. The `pattern_match` node
6//! and the vector catalog's profile filter share it.
7
8use regex::Regex;
9
10/// Which dialect [`promote_pattern`] / [`compile_pattern`] picked.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum PatternDialect {
13 /// No regex metachars and no `*` — strict, anchored full-string
14 /// match (`^src$`).
15 Literal,
16 /// `*` (and no other regex metachar) — each `*` expands to `.*`,
17 /// anchored.
18 Glob,
19 /// Any regex metachar — a full Rust regex, anchored `^(?:src)$`.
20 Regex,
21}
22
23impl PatternDialect {
24 /// The dialect's name, as `pattern_dialect` spells it.
25 pub fn as_str(self) -> &'static str {
26 match self {
27 PatternDialect::Literal => "literal",
28 PatternDialect::Glob => "glob",
29 PatternDialect::Regex => "regex",
30 }
31 }
32}
33
34/// Metachars whose presence means "already a regex — don't glob-expand".
35/// Excludes `*`, which is the glob marker.
36const PATTERN_REGEX_METACHARS: &[char] = &[
37 '.', '+', '?', '(', ')', '|', '[', ']', '{', '}', '^', '$', '\\',
38];
39
40/// Classify `source` and return its anchored regex source + dialect.
41/// Pure — does not compile. Promotion rules:
42/// - any regex metachar → full regex, `^(?:src)$`
43/// - else if it contains `*` → glob, each `*` → `.*`, `^…$`
44/// - else → literal, `^escape(src)$`
45pub fn promote_pattern(source: &str) -> (String, PatternDialect) {
46 let has_regex_metachar = source.chars().any(|c| PATTERN_REGEX_METACHARS.contains(&c));
47 let has_glob_star = source.contains('*');
48 if has_regex_metachar {
49 (format!("^(?:{source})$"), PatternDialect::Regex)
50 } else if has_glob_star {
51 // regex::escape renders `*` as `\*`; turn those back into `.*`.
52 let pattern = regex::escape(source).replace("\\*", ".*");
53 (format!("^{pattern}$"), PatternDialect::Glob)
54 } else {
55 (
56 format!("^{}$", regex::escape(source)),
57 PatternDialect::Literal,
58 )
59 }
60}
61
62/// Promote and compile `source` into an anchored [`Regex`].
63pub fn compile_pattern(source: &str) -> Result<(Regex, PatternDialect), String> {
64 let (anchored, dialect) = promote_pattern(source);
65 let re = Regex::new(&anchored).map_err(|e| {
66 format!(
67 "pattern '{source}' did not compile (dialect={}): {e}",
68 dialect.as_str()
69 )
70 })?;
71 Ok((re, dialect))
72}