Skip to main content

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}