use std::borrow::Cow;
pub struct Regex(regex::Regex);
#[non_exhaustive]
pub struct PatternError {
pattern: String,
message: String,
kind: PatternErrorKind,
}
impl PatternError {
#[must_use]
pub fn pattern(&self) -> &str {
&self.pattern
}
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
#[must_use]
pub fn kind(&self) -> PatternErrorKind {
self.kind
}
}
impl core::fmt::Debug for PatternError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("PatternError")
.field("pattern", &self.pattern)
.field("message", &self.message)
.field("kind", &self.kind)
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum PatternErrorKind {
PatternTooLarge {
limit: usize,
actual: usize,
},
CompiledTooLarge,
Syntax,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct PatternConfig {
pattern_limit: usize,
compiled_size_limit: usize,
nesting_limit: u32,
input_limit: usize,
output_limit: usize,
}
impl PatternConfig {
#[must_use]
pub fn with_limits(
pattern_limit: usize,
compiled_size_limit: usize,
nesting_limit: u32,
input_limit: usize,
output_limit: usize,
) -> Self {
Self {
pattern_limit,
compiled_size_limit,
nesting_limit,
input_limit,
output_limit,
}
}
}
impl Default for PatternConfig {
fn default() -> Self {
Self {
pattern_limit: 64 * 1024,
compiled_size_limit: 10 * 1024 * 1024,
nesting_limit: 250,
input_limit: 1024 * 1024,
output_limit: 4 * 1024 * 1024,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum PatternRunError {
InputTooLarge {
limit: usize,
actual: usize,
},
OutputTooLarge {
limit: usize,
attempted: usize,
},
AllocationFailed {
cause: std::collections::TryReserveError,
},
MissingWholeMatch,
}
impl core::fmt::Display for PatternRunError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match *self {
Self::InputTooLarge { limit, actual } => {
write!(f, "input is {actual} bytes; limit is {limit}")
}
Self::OutputTooLarge { limit, attempted } => write!(
f,
"replacement would produce {attempted} bytes; limit is {limit}"
),
Self::AllocationFailed { ref cause } => {
write!(f, "replacement output allocation failed: {cause}")
}
Self::MissingWholeMatch => {
f.write_str("regex engine returned captures without group zero")
}
}
}
}
impl std::error::Error for PatternRunError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match *self {
Self::AllocationFailed { ref cause } => Some(cause),
_ => None,
}
}
}
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| pattern_error(pattern, err))
}
#[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| {
#[cfg(test)]
count_produced_match();
convert_match(matched)
})
}
#[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_borrowed<'t>(&self, text: &'t str, replacement: &str) -> Cow<'t, str> {
self.0.replace(text, replacement)
}
#[must_use]
pub fn replace_all(&self, text: &str, replacement: &str) -> String {
self.0.replace_all(text, replacement).into_owned()
}
#[must_use]
pub fn replace_all_borrowed<'t>(&self, text: &'t str, replacement: &str) -> Cow<'t, str> {
self.0.replace_all(text, replacement)
}
pub fn split<'t>(&self, text: &'t str) -> impl Iterator<Item = &'t str> {
self.0.split(text)
}
}
impl Regex {
pub fn with_config(pattern: &str, config: PatternConfig) -> Result<BoundedRegex, PatternError> {
if pattern.len() > config.pattern_limit {
let refusal = Err(PatternError {
pattern: pattern.chars().take(64).collect(),
message: format!(
"pattern is {} bytes; limit is {}",
pattern.len(),
config.pattern_limit
),
kind: PatternErrorKind::PatternTooLarge {
limit: config.pattern_limit,
actual: pattern.len(),
},
});
#[cfg(feature = "trace")]
crate::trace::debug!(error = ?refusal.as_ref().err(), "with_config: returning an error to the caller");
return refusal;
}
let mut builder = regex::RegexBuilder::new(pattern);
builder
.size_limit(config.compiled_size_limit)
.nest_limit(config.nesting_limit);
builder
.build()
.map(|engine| BoundedRegex { engine, config })
.map_err(|err| pattern_error(pattern, err))
}
}
fn pattern_error(pattern: &str, source: regex::Error) -> PatternError {
let kind = match source.clone() {
regex::Error::Syntax(_) => PatternErrorKind::Syntax,
regex::Error::CompiledTooBig(_) => PatternErrorKind::CompiledTooLarge,
_ => PatternErrorKind::Other,
};
PatternError {
pattern: pattern.to_owned(),
message: source.to_string(),
kind,
}
}
impl core::fmt::Debug for Regex {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_tuple("Regex").field(&self.0.as_str()).finish()
}
}
pub struct BoundedRegex {
engine: regex::Regex,
config: PatternConfig,
}
impl core::fmt::Debug for BoundedRegex {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("BoundedRegex")
.field("pattern", &self.engine.as_str())
.field("config", &self.config)
.finish()
}
}
impl BoundedRegex {
fn check_input(&self, text: &str) -> Result<(), PatternRunError> {
if text.len() > self.config.input_limit {
let refusal = Err(PatternRunError::InputTooLarge {
limit: self.config.input_limit,
actual: text.len(),
});
#[cfg(feature = "trace")]
crate::trace::debug!(error = ?refusal.as_ref().err(), "check_input: returning an error to the caller");
return refusal;
}
Ok(())
}
pub fn is_match(&self, text: &str) -> Result<bool, PatternRunError> {
self.check_input(text)?;
Ok(self.engine.is_match(text))
}
pub fn find<'t>(&self, text: &'t str) -> Result<Option<Match<'t>>, PatternRunError> {
self.check_input(text)?;
Ok(self.engine.find(text).map(convert_match))
}
pub fn find_all<'r, 't>(
&'r self,
text: &'t str,
) -> Result<impl Iterator<Item = Match<'t>> + 'r, PatternRunError>
where
't: 'r,
{
self.check_input(text)?;
Ok(self.engine.find_iter(text).map(|matched| {
#[cfg(test)]
count_produced_match();
convert_match(matched)
}))
}
pub fn captures<'t>(
&self,
text: &'t str,
) -> Result<Option<Vec<Option<&'t str>>>, PatternRunError> {
self.check_input(text)?;
Ok(self.engine.captures(text).map(|caps| {
(0..caps.len())
.map(|index| caps.get(index).map(|matched| matched.as_str()))
.collect()
}))
}
pub fn split<'r, 't>(
&'r self,
text: &'t str,
) -> Result<impl Iterator<Item = &'t str> + 'r, PatternRunError>
where
't: 'r,
{
self.check_input(text)?;
Ok(self.engine.split(text))
}
pub fn replace<'t>(
&self,
text: &'t str,
replacement: &str,
) -> Result<Cow<'t, str>, PatternRunError> {
self.replace_matches(text, replacement, 1)
}
pub fn replace_all<'t>(
&self,
text: &'t str,
replacement: &str,
) -> Result<Cow<'t, str>, PatternRunError> {
self.replace_matches(text, replacement, 0)
}
fn replace_matches<'t>(
&self,
text: &'t str,
replacement: &str,
count: usize,
) -> Result<Cow<'t, str>, PatternRunError> {
self.check_input(text)?;
let mut captures = self.engine.captures_iter(text);
let Some(first) = captures.next() else {
if text.len() > self.config.output_limit {
let refusal = Err(PatternRunError::OutputTooLarge {
limit: self.config.output_limit,
attempted: text.len(),
});
#[cfg(feature = "trace")]
crate::trace::debug!(error = ?refusal.as_ref().err(), "replace_matches: returning an error to the caller");
return refusal;
}
return Ok(Cow::Borrowed(text));
};
let mut output = BoundedString::new(self.config.output_limit);
let mut cursor = 0;
let mut remaining = count;
let mut current = Some(first);
while let Some(groups) = current {
let whole = groups.get(0).ok_or(PatternRunError::MissingWholeMatch)?;
output.push_str(&text[cursor..whole.start()])?;
expand_replacement(&groups, replacement, &mut output)?;
cursor = whole.end();
if count != 0 {
remaining = remaining.saturating_sub(1);
if remaining == 0 {
break;
}
}
current = captures.next();
}
output.push_str(&text[cursor..])?;
Ok(Cow::Owned(output.value))
}
}
fn convert_match<'t>(matched: regex::Match<'t>) -> Match<'t> {
Match {
text: matched.as_str(),
start: matched.start(),
end: matched.end(),
}
}
#[cfg(test)]
thread_local! {
static PRODUCED_MATCHES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
fn count_produced_match() {
PRODUCED_MATCHES.with(|count| count.set(count.get().saturating_add(1)));
}
struct BoundedString {
value: String,
limit: usize,
}
impl BoundedString {
fn new(limit: usize) -> Self {
Self {
value: String::new(),
limit,
}
}
fn push_str(&mut self, value: &str) -> Result<(), PatternRunError> {
let attempted = match self.value.len().checked_add(value.len()) {
Some(attempted) => attempted,
None => usize::MAX,
};
if attempted > self.limit {
let refusal = Err(PatternRunError::OutputTooLarge {
limit: self.limit,
attempted,
});
#[cfg(feature = "trace")]
crate::trace::debug!(error = ?refusal.as_ref().err(), "push_str: returning an error to the caller");
return refusal;
}
if self.value.capacity() < attempted {
let target = self
.value
.len()
.saturating_mul(2)
.clamp(attempted, self.limit);
self.value
.try_reserve_exact(target.saturating_sub(self.value.len()))
.map_err(|cause| PatternRunError::AllocationFailed { cause })?;
}
self.value.push_str(value);
Ok(())
}
}
fn expand_replacement(
captures: ®ex::Captures<'_>,
replacement: &str,
output: &mut BoundedString,
) -> Result<(), PatternRunError> {
let bytes = replacement.as_bytes();
let mut cursor = 0;
let mut literal_start = 0;
while cursor < bytes.len() {
match next_expansion(captures, replacement, output, cursor, &mut literal_start)? {
ExpansionStep::Skipped => {
cursor = cursor.saturating_add(1);
}
ExpansionStep::Done => {
output.push_str(&replacement[literal_start..])?;
return Ok(());
}
ExpansionStep::Consumed { next } => {
cursor = next;
}
}
}
output.push_str(&replacement[literal_start..])
}
enum ExpansionStep {
Skipped,
Consumed {
next: usize,
},
Done,
}
fn next_expansion(
captures: ®ex::Captures<'_>,
replacement: &str,
output: &mut BoundedString,
mut cursor: usize,
literal_start: &mut usize,
) -> Result<ExpansionStep, PatternRunError> {
let bytes = replacement.as_bytes();
if bytes[cursor] != b'$' {
return Ok(ExpansionStep::Skipped);
}
let reference_start = cursor.saturating_add(1);
if reference_start < bytes.len() && bytes[reference_start] == b'$' {
output.push_str(&replacement[*literal_start..cursor])?;
output.push_str("$")?;
cursor = cursor.saturating_add(2);
*literal_start = cursor;
return Ok(ExpansionStep::Consumed { next: cursor });
}
let (reference_end, name_start, braced) =
if reference_start < bytes.len() && bytes[reference_start] == b'{' {
let name_start = reference_start.saturating_add(1);
match bytes[name_start..].iter().position(|byte| *byte == b'}') {
Some(offset) => (name_start.saturating_add(offset), name_start, true),
None => return Ok(ExpansionStep::Skipped),
}
} else {
let mut end = reference_start;
while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
end = end.saturating_add(1);
}
if end == reference_start {
return Ok(ExpansionStep::Skipped);
}
(end, reference_start, false)
};
output.push_str(&replacement[*literal_start..cursor])?;
let name = &replacement[name_start..reference_end];
let matched = match name.parse::<usize>() {
Ok(index) => captures.get(index),
Err(_) => captures.name(name),
};
if let Some(matched) = matched {
output.push_str(matched.as_str())?;
}
let next = if braced {
reference_end.saturating_add(1)
} else {
reference_end
};
*literal_start = next;
if next >= bytes.len() {
return Ok(ExpansionStep::Done);
}
Ok(ExpansionStep::Consumed { next })
}
#[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::*;
fn config(input_limit: usize, output_limit: usize) -> PatternConfig {
PatternConfig::with_limits(64 * 1024, 1_000_000, 250, input_limit, output_limit)
}
fn produced_matches() -> usize {
PRODUCED_MATCHES.with(std::cell::Cell::get)
}
fn reset_produced_matches() {
PRODUCED_MATCHES.with(|count| count.set(0));
}
struct EagerResult<'t> {
matches: Vec<Match<'t>>,
growth_events: usize,
capacity_bytes: usize,
}
fn eager_mutant<'t>(re: &BoundedRegex, text: &'t str) -> EagerResult<'t> {
let mut matches = Vec::new();
let mut growth_events = 0usize;
let mut capacity_bytes = 0usize;
for matched in re.engine.find_iter(text) {
count_produced_match();
let old_capacity = matches.capacity();
matches.push(convert_match(matched));
let new_capacity = matches.capacity();
if new_capacity > old_capacity {
growth_events = growth_events.saturating_add(1);
capacity_bytes = capacity_bytes.saturating_add(
new_capacity
.saturating_sub(old_capacity)
.saturating_mul(std::mem::size_of::<Match<'t>>()),
);
}
}
EagerResult {
matches,
growth_events,
capacity_bytes,
}
}
#[test]
fn producer_counter_detects_eager_collection_mutant() -> Result<(), Box<dyn std::error::Error>>
{
let re = Regex::with_config(r"\d", config(32, 32))?;
reset_produced_matches();
let first = re.find_all("1x2x3x4x5x6x7x8")?.take(1).next();
assert_eq!(
first.map(|item| (item.text, item.start, item.end)),
Some(("1", 0, 1))
);
assert_eq!(produced_matches(), 1);
reset_produced_matches();
let eager = eager_mutant(&re, "1x2x3x4x5x6x7x8");
assert_eq!(eager.matches.len(), 8);
assert_eq!(produced_matches(), 8);
assert!(eager.growth_events > 0);
assert!(eager.capacity_bytes >= 8 * std::mem::size_of::<Match<'_>>());
Ok(())
}
#[test]
fn configured_limits_refuse_input_and_amplified_output()
-> Result<(), Box<dyn std::error::Error>> {
let bounded = Regex::with_config("a", config(2, 5))?;
assert_eq!(
bounded.is_match("aaa"),
Err(PatternRunError::InputTooLarge {
limit: 2,
actual: 3
})
);
let amplify = Regex::with_config("a", config(8, 5))?;
assert_eq!(
amplify.replace_all("aaa", "XX"),
Err(PatternRunError::OutputTooLarge {
limit: 5,
attempted: 6
})
);
Ok(())
}
#[test]
fn bounded_replacement_expands_exactly_like_the_engine()
-> Result<(), Box<dyn std::error::Error>> {
let pattern = r"(?<name>a)(b)";
let unbounded = Regex::new(pattern)?;
let bounded = Regex::with_config(pattern, config(64, 4096))?;
for template in [
"${+1}",
"$1a",
"${1}a",
"$01",
"${01}",
"$",
"a$",
"$-",
"${",
"${unclosed",
"$$1",
"$$$1",
"${name}",
"$name",
"$name$2",
"$99999999999999999999",
"${99999999999999999999}",
"${ 1}",
"${}",
"$0$0",
"é$2é",
] {
assert_eq!(
bounded.replace_all("abxab", template)?,
unbounded.replace_all("abxab", template),
"template {template:?} must expand as the engine expands it"
);
}
Ok(())
}
#[test]
fn replacement_borrows_no_match_and_preserves_capture_rules()
-> Result<(), Box<dyn std::error::Error>> {
let no_match = Regex::with_config("z", config(32, 32))?;
let input = String::from("é-data");
let output = no_match.replace_all(&input, "x")?;
assert!(matches!(output, Cow::Borrowed(value) if value == input));
let narrow_output = Regex::with_config("z", config(32, 3))?;
assert_eq!(
narrow_output.replace_all(&input, "x"),
Err(PatternRunError::OutputTooLarge {
limit: 3,
attempted: input.len()
})
);
let captures = Regex::with_config(r"(a)?()", config(32, 32))?
.captures("b")?
.ok_or("empty capture missing")?;
assert_eq!(captures[1], None);
assert_eq!(captures[2], Some(""));
let expansion = Regex::with_config(r"(?<left>a)(?<right>b)", config(32, 32))?;
assert_eq!(expansion.replace("ab", "${right}$left$$")?, "ba$");
let unicode = Regex::with_config("é", config(32, 32))?
.find("xéy")?
.ok_or("match missing")?;
assert_eq!((unicode.text, unicode.start, unicode.end), ("é", 1, 3));
assert_eq!(
Regex::with_config("", config(32, 32))?.replace_all("é", "-")?,
"-é-"
);
Ok(())
}
#[test]
fn greedy_adversary_and_literal_control_keep_exact_match_workloads()
-> Result<(), Box<dyn std::error::Error>> {
let adversary = Regex::with_config(r".*[^A-Z]|[A-Z]", config(16, 16))?;
let spans = adversary
.find_all("AAAA")?
.map(|item| (item.start, item.end))
.collect::<Vec<_>>();
assert_eq!(spans, vec![(0, 1), (1, 2), (2, 3), (3, 4)]);
assert_eq!(adversary.replace_all("AAAA", "X")?, "XXXX");
assert_eq!(
adversary.split("AAAA")?.collect::<Vec<_>>(),
vec!["", "", "", "", ""]
);
let literal = Regex::with_config("A", config(16, 16))?;
let literal_spans = literal
.find_all("AAAA")?
.map(|item| (item.start, item.end))
.collect::<Vec<_>>();
assert_eq!(literal_spans, vec![(0, 1), (1, 2), (2, 3), (3, 4)]);
Ok(())
}
#[test]
fn configured_pattern_compile_size_and_nesting_limits_are_enforced()
-> Result<(), Box<dyn std::error::Error>> {
let source_error =
Regex::with_config("long", PatternConfig::with_limits(3, 1_000, 250, 16, 16))
.err()
.ok_or("source pattern limit was ignored")?;
assert_eq!(
source_error.kind(),
PatternErrorKind::PatternTooLarge {
limit: 3,
actual: 4
}
);
assert_eq!(source_error.pattern(), "long");
let large_source = "x".repeat(1_000);
let excerpt_error = Regex::with_config(
&large_source,
PatternConfig::with_limits(3, 1_000, 250, 16, 16),
)
.err()
.ok_or("oversize source pattern was compiled")?;
assert_eq!(
excerpt_error.kind(),
PatternErrorKind::PatternTooLarge {
limit: 3,
actual: 1_000
}
);
assert!(excerpt_error.pattern().len() <= 64);
let compile_error =
Regex::with_config(r"\w", PatternConfig::with_limits(1024, 1, 250, 16, 16))
.err()
.ok_or("compiled-size limit was ignored")?;
assert_eq!(compile_error.kind(), PatternErrorKind::CompiledTooLarge);
let nesting_error =
Regex::with_config("ab", PatternConfig::with_limits(1024, 10_000, 0, 16, 16))
.err()
.ok_or("nesting limit was ignored")?;
assert_eq!(nesting_error.kind(), PatternErrorKind::Syntax);
Ok(())
}
#[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"));
let valid_control_pattern = Regex::with_config("[\n]", PatternConfig::default())?;
let debug = format!("{valid_control_pattern:?}");
assert!(!debug.contains('\n'));
assert!(debug.contains("\\n"));
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(())
}
}