string-patterns 0.4.0

Makes it easier to work with common string patterns and regular expressions in Rust, adding convenient regex match and replace methods (pattern_match and pattern_replace) to the standard String type as well to vectors of strings
Documentation
use crate::utils::build_regex;
use regex::{Error, Regex};
use std::collections::HashMap;
use std::sync::{OnceLock, RwLock};

type CacheKey = (String, bool);

fn cache() -> &'static RwLock<HashMap<CacheKey, Regex>> {
    static CACHE: OnceLock<RwLock<HashMap<CacheKey, Regex>>> = OnceLock::new();
    CACHE.get_or_init(|| RwLock::new(HashMap::new()))
}

/// Runs `f` against the compiled `Regex` for `(pattern, case_insensitive)`, compiling it only
/// once per distinct pair for the life of the process. Every trait method in this crate that
/// needs a `Regex` — matching, splitting, capturing, replacing — goes through this instead of
/// calling `build_regex` directly, so none of them ever recompile a pattern they've already
/// seen, regardless of how many times or where they're called from (e.g. once per row across a
/// million-row batch), with no need to build or pass around a `Regex` (or `Arc<Regex>`) yourself.
///
/// `f` runs against the *same* cached `Regex` instance (borrowed from behind a read lock on a
/// cache hit) rather than a clone. This matters: `Regex::clone()` only cheaply shares the
/// compiled program, not its internal lazy-DFA scratch cache, so cloning a `Regex` and then
/// immediately matching once against the fresh clone was measured at ~8µs/call versus ~26ns for
/// matching against an already-warm instance — worse than the ~90ns cost of recompiling a short
/// pattern from scratch. So this never clones the `Regex` out of the cache; it runs the
/// operation while still holding the lock instead.
///
/// This is the right choice when you have a fixed, small set of patterns (hardcoded or
/// user-configured validators) applied repeatedly across many inputs. It is not a general
/// unbounded cache: every distinct pattern string ever passed stays cached for the life of the
/// process, so it isn't a good fit for patterns built from unbounded, ever-changing input (e.g.
/// interpolating row data directly into the pattern).
pub(crate) fn with_cached_regex<R>(
    pattern: &str,
    case_insensitive: bool,
    f: impl FnOnce(&Regex) -> R,
) -> Result<R, Error> {
    let key = (pattern.to_string(), case_insensitive);
    if let Some(re) = cache().read().unwrap().get(&key) {
        return Ok(f(re));
    }
    let re = build_regex(pattern, case_insensitive)?;
    let result = f(&re);
    cache().write().unwrap().entry(key).or_insert(re);
    Ok(result)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn compiles_once_and_reuses_the_cached_regex() {
        let pattern = r"\bfloat(ing)?.*?error";
        let cases = [
            ("floating point error", true),
            ("no match here", false),
            ("float error too", true),
        ];
        for (subject, want) in cases {
            // Every call goes through the same cache key; correctness must hold regardless of
            // whether this particular call is the one that compiles or a later cache hit.
            let matched = with_cached_regex(pattern, true, |re| re.is_match(subject)).unwrap();
            assert_eq!(matched, want, "{subject}");
        }
    }

    #[test]
    fn case_sensitivity_is_part_of_the_cache_key() {
        assert!(with_cached_regex("error", true, |re| re.is_match("ERROR")).unwrap());
        assert!(!with_cached_regex("error", false, |re| re.is_match("ERROR")).unwrap());
        assert!(with_cached_regex("error", false, |re| re.is_match("error")).unwrap());
    }

    #[test]
    fn invalid_pattern_errors_without_poisoning_the_cache() {
        let bad_pattern = r"\bgene(s?\b"; // unbalanced group
        assert!(with_cached_regex(bad_pattern, true, |re| re.is_match("anything")).is_err());
        // A later, valid pattern still works fine.
        assert!(with_cached_regex(r"\bgenes?\b", true, |re| re.is_match("genes")).unwrap());
    }
}