use std::collections::BTreeMap;
use std::num::NonZeroUsize;
use std::sync::{Mutex, OnceLock};
use lru::LruCache;
use regex::Regex;
use crate::error::ConfigError;
#[derive(Debug, Clone)]
pub struct Pattern {
regex: Regex,
names: Vec<String>,
}
const PATTERN_CACHE_CAP: usize = 2048;
fn pattern_cache() -> &'static Mutex<LruCache<String, Pattern>> {
static CACHE: OnceLock<Mutex<LruCache<String, Pattern>>> = OnceLock::new();
CACHE.get_or_init(|| {
Mutex::new(LruCache::new(
NonZeroUsize::new(PATTERN_CACHE_CAP).expect("cap > 0"),
))
})
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Match {
pub params: BTreeMap<String, String>,
pub splat: Option<String>,
}
impl Pattern {
pub fn compile(pattern: &str) -> Result<Self, ConfigError> {
Self::compile_with(pattern, false)
}
pub fn compile_with(pattern: &str, case_insensitive: bool) -> Result<Self, ConfigError> {
let cache_key = if case_insensitive {
format!("\u{1}{pattern}")
} else {
pattern.to_string()
};
if let Some(cached) = pattern_cache().lock().unwrap().get(&cache_key) {
return Ok(cached.clone());
}
let compiled = Self::compile_uncached(pattern, case_insensitive)?;
pattern_cache()
.lock()
.unwrap()
.put(cache_key, compiled.clone());
Ok(compiled)
}
fn compile_uncached(pattern: &str, case_insensitive: bool) -> Result<Self, ConfigError> {
let mut regex = if case_insensitive {
String::from("(?i)^")
} else {
String::from("^")
};
let mut names = Vec::new();
let mut has_splat = false;
let bytes = pattern.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'*' => {
if bytes.get(i + 1) == Some(&b'*') {
if has_splat {
return Err(ConfigError::pattern(pattern, "at most one `**` allowed"));
}
has_splat = true;
regex.push_str("(?P<splat>.*)");
i += 2;
} else {
regex.push_str("[^/]*");
i += 1;
}
}
b':' => {
let start = i + 1;
let mut j = start;
while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_')
{
j += 1;
}
if j == start {
return Err(ConfigError::pattern(
pattern,
"`:` must be followed by a name",
));
}
let name = &pattern[start..j];
if name == "splat" {
return Err(ConfigError::pattern(
pattern,
"`:splat` is reserved; use `**`",
));
}
regex.push_str(&format!("(?P<{name}>[^/]+)"));
names.push(name.to_string());
i = j;
}
_ => {
let ch = pattern[i..].chars().next().expect("char boundary");
regex.push_str(®ex::escape(ch.encode_utf8(&mut [0u8; 4])));
i += ch.len_utf8();
}
}
}
regex.push('$');
let regex =
Regex::new(®ex).map_err(|err| ConfigError::pattern(pattern, err.to_string()))?;
Ok(Self { regex, names })
}
pub fn is_match(&self, path: &str) -> bool {
self.regex.is_match(path)
}
pub fn match_path(&self, path: &str) -> Option<Match> {
let caps = self.regex.captures(path)?;
let mut params = BTreeMap::new();
for name in &self.names {
if let Some(value) = caps.name(name) {
params.insert(name.clone(), value.as_str().to_string());
}
}
let splat = caps.name("splat").map(|m| m.as_str().to_string());
Some(Match { params, splat })
}
}
impl Match {
pub fn expand(&self, target: &str) -> String {
let mut out = String::with_capacity(target.len());
let bytes = target.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b':' {
let start = i + 1;
let mut j = start;
while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
j += 1;
}
let name = &target[start..j];
if name == "splat" {
out.push_str(self.splat.as_deref().unwrap_or(""));
} else if let Some(value) = self.params.get(name) {
out.push_str(value);
} else {
out.push(':');
out.push_str(name);
}
i = j;
} else {
let ch = target[i..].chars().next().expect("char boundary");
out.push(ch);
i += ch.len_utf8();
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compile_is_cached() {
let pat = "/cache-probe/:id/**";
let a = Pattern::compile(pat).unwrap();
let b = Pattern::compile(pat).unwrap();
assert!(
std::ptr::eq(a.regex.as_str().as_ptr(), b.regex.as_str().as_ptr()),
"second compile should return the cached pattern"
);
let m = b.match_path("/cache-probe/42/x/y").unwrap();
assert_eq!(m.params.get("id").map(String::as_str), Some("42"));
assert_eq!(m.splat.as_deref(), Some("x/y"));
}
#[test]
fn placeholder_capture_and_expand() {
let p = Pattern::compile("/old/:slug").unwrap();
let m = p.match_path("/old/hello").unwrap();
assert_eq!(m.params.get("slug").map(String::as_str), Some("hello"));
assert_eq!(m.expand("/new/:slug"), "/new/hello");
assert!(p.match_path("/old/a/b").is_none()); }
#[test]
fn splat_capture_and_expand() {
let p = Pattern::compile("/api/**").unwrap();
let m = p.match_path("/api/v1/users").unwrap();
assert_eq!(m.splat.as_deref(), Some("v1/users"));
assert_eq!(
m.expand("https://backend/:splat"),
"https://backend/v1/users"
);
}
#[test]
fn star_is_within_segment() {
let p = Pattern::compile("/assets/*").unwrap();
assert!(p.is_match("/assets/app.css"));
assert!(!p.is_match("/assets/sub/app.css"));
}
#[test]
fn double_star_matches_extension_anywhere() {
let p = Pattern::compile("**.js").unwrap();
assert!(p.is_match("/app.js"));
assert!(p.is_match("/a/b/c.js"));
assert!(!p.is_match("/app.css"));
}
#[test]
fn rejects_bad_patterns() {
assert!(Pattern::compile("/a/**/b/**").is_err()); assert!(Pattern::compile("/x/:").is_err()); }
#[test]
fn literal_dots_are_escaped() {
let p = Pattern::compile("/file.txt").unwrap();
assert!(p.is_match("/file.txt"));
assert!(!p.is_match("/fileXtxt"));
}
}