pub struct Regex(regex::Regex);
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct PatternError {
pub pattern: String,
pub message: String,
}
impl core::fmt::Display for PatternError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"invalid pattern `{}`: {}",
self.pattern.escape_debug(),
self.message.escape_debug()
)
}
}
impl std::error::Error for PatternError {}
impl Regex {
pub fn new(pattern: &str) -> Result<Self, PatternError> {
regex::Regex::new(pattern)
.map(Self)
.map_err(|err| PatternError {
pattern: pattern.to_owned(),
message: err.to_string(),
})
}
#[must_use]
pub fn is_match(&self, text: &str) -> bool {
self.0.is_match(text)
}
#[must_use]
pub fn find<'t>(&self, text: &'t str) -> Option<Match<'t>> {
self.0.find(text).map(|matched| Match {
text: matched.as_str(),
start: matched.start(),
end: matched.end(),
})
}
pub fn find_all<'t>(&self, text: &'t str) -> impl Iterator<Item = Match<'t>> {
self.0.find_iter(text).map(|matched| Match {
text: matched.as_str(),
start: matched.start(),
end: matched.end(),
})
}
#[must_use]
pub fn captures<'t>(&self, text: &'t str) -> Option<Vec<Option<&'t str>>> {
self.0.captures(text).map(|caps| {
(0..caps.len())
.map(|i| caps.get(i).map(|matched| matched.as_str()))
.collect()
})
}
#[must_use]
pub fn replace(&self, text: &str, replacement: &str) -> String {
self.0.replace(text, replacement).into_owned()
}
#[must_use]
pub fn replace_all(&self, text: &str, replacement: &str) -> String {
self.0.replace_all(text, replacement).into_owned()
}
pub fn split<'t>(&self, text: &'t str) -> impl Iterator<Item = &'t str> {
self.0.split(text)
}
}
impl core::fmt::Debug for Regex {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "Regex({})", self.0.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Match<'t> {
pub text: &'t str,
pub start: usize,
pub end: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compiles_valid_pattern() {
assert!(Regex::new(r"\d+").is_ok());
}
#[test]
fn rejects_invalid_pattern() {
assert!(Regex::new(r"[unclosed").is_err());
}
#[test]
fn error_display_escapes_control_characters() -> Result<(), Box<dyn std::error::Error>> {
let Err(error) = Regex::new("(\n") else {
return Err("a pattern with an unclosed group must not compile".into());
};
let rendered = error.to_string();
assert!(
!rendered.contains('\n'),
"raw newline survived: {rendered:?}"
);
assert!(rendered.contains("invalid pattern"));
Ok(())
}
#[test]
fn is_match_finds_substring() -> Result<(), PatternError> {
let re = Regex::new(r"\d+")?;
assert!(re.is_match("abc123def"));
assert!(!re.is_match("abcdef"));
Ok(())
}
#[test]
fn find_returns_first_match() -> Result<(), Box<dyn std::error::Error>> {
let re = Regex::new(r"\d+")?;
let matched = re
.find("abc123def456")
.ok_or("the digit pattern must match abc123def456")?;
assert_eq!(matched.text, "123");
assert_eq!(matched.start, 3);
assert_eq!(matched.end, 6);
Ok(())
}
#[test]
fn find_all_returns_every_match() -> Result<(), PatternError> {
let re = Regex::new(r"\d+")?;
let matches: Vec<Match<'_>> = re.find_all("a1b22c333").collect();
assert_eq!(matches.len(), 3);
assert_eq!(matches[0].text, "1");
assert_eq!(matches[1].text, "22");
assert_eq!(matches[2].text, "333");
Ok(())
}
#[test]
fn find_all_stops_where_the_caller_stops() -> Result<(), PatternError> {
let re = Regex::new(r"\d+")?;
let mut seen = 0usize;
let first = re
.find_all("a1b22c333d4444")
.inspect(|_| seen = seen.saturating_add(1))
.take(1)
.next();
assert_eq!(first.map(|matched| matched.text), Some("1"));
assert_eq!(seen, 1);
Ok(())
}
#[test]
fn captures_extracts_groups() -> Result<(), Box<dyn std::error::Error>> {
let re = Regex::new(r"(\w+)@(\w+)\.(\w+)")?;
let captures = re
.captures("user@host.com")
.ok_or("the three-group pattern must match user@host.com")?;
assert_eq!(captures[1], Some("user"));
assert_eq!(captures[2], Some("host"));
assert_eq!(captures[3], Some("com"));
Ok(())
}
#[test]
fn replace_substitutes_first() -> Result<(), PatternError> {
let re = Regex::new(r"\d+")?;
assert_eq!(re.replace("a1b2c3", "X"), "aXb2c3");
Ok(())
}
#[test]
fn replace_all_substitutes_every_match() -> Result<(), PatternError> {
let re = Regex::new(r"\d+")?;
assert_eq!(re.replace_all("a1b2c3", "X"), "aXbXcX");
Ok(())
}
#[test]
fn split_divides_on_pattern() -> Result<(), PatternError> {
let re = Regex::new(r"[,;]\s*")?;
assert_eq!(
re.split("a, b; c,d").collect::<Vec<&str>>(),
vec!["a", "b", "c", "d"]
);
Ok(())
}
#[test]
fn error_includes_pattern_text() -> Result<(), Box<dyn std::error::Error>> {
let Err(error) = Regex::new(r"(unclosed") else {
return Err("a pattern with an unclosed group must not compile".into());
};
assert!(error.pattern.contains("unclosed"));
assert!(!error.message.is_empty());
Ok(())
}
}