use crate::{HawDBError, Result, ValidatedRegex, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum StringFunction {
Trim,
RegexpReplace,
}
impl StringFunction {
pub fn from_function_name(name: &str) -> Option<Self> {
[Self::Trim, Self::RegexpReplace]
.into_iter()
.find(|f| name.eq_ignore_ascii_case(f.function_name()))
}
pub const fn function_name(self) -> &'static str {
match self {
Self::Trim => "trim",
Self::RegexpReplace => "regexp_replace",
}
}
pub const fn arity(self) -> (usize, usize) {
match self {
Self::Trim => (1, 1),
Self::RegexpReplace => (3, 4),
}
}
pub fn apply(self, values: &[&Value], admit: impl FnMut(usize) -> Result<()>) -> Result<Value> {
self.apply_prepared(values, None, admit)
}
#[doc(hidden)]
pub fn apply_prepared(
self,
values: &[&Value],
pattern: Option<&ValidatedRegex>,
mut admit: impl FnMut(usize) -> Result<()>,
) -> Result<Value> {
let (min, max) = self.arity();
if !(min..=max).contains(&values.len()) {
return Err(HawDBError::Execution(format!(
"{}() expects {min}..={max} arguments",
self.function_name()
)));
}
if values.iter().any(|v| matches!(v, Value::Null)) {
return Ok(Value::Null);
}
let values = values
.iter()
.map(|value| match value {
Value::String(text) => Ok(text.as_str()),
_ => Err(HawDBError::Execution(format!(
"{}() requires string arguments",
self.function_name()
))),
})
.collect::<Result<Vec<_>>>()?;
if self == Self::Trim {
let value = values[0].trim();
admit(value.len())?;
return Ok(Value::String(value.to_owned()));
}
let global = match values.get(3).copied().unwrap_or("") {
"" => false,
"g" => true,
_ => {
return Err(HawDBError::Execution(
"regexp_replace() supports only the 'g' flag".into(),
))
}
};
let mut escapes = values[2].chars();
while let Some(ch) = escapes.next() {
if ch == '\\' && !matches!(escapes.next(), Some('\\' | '&' | '1'..='9')) {
return Err(HawDBError::Execution(
"regexp_replace() unsupported replacement escape".into(),
));
}
}
let dynamic;
let pattern = match pattern {
Some(pattern) if pattern.as_str() == values[1] => pattern,
Some(_) => {
return Err(HawDBError::Execution(
"regexp_replace() prepared pattern does not match its argument".into(),
))
}
None => {
dynamic = ValidatedRegex::new(values[1]).map_err(|error| match error {
HawDBError::Semantic(message) => HawDBError::Execution(message),
error => error,
})?;
&dynamic
}
};
pattern.with_compiled(|regex| {
let mut bytes = 0usize;
replacement_pieces(regex, values[0], values[2], global, |piece| {
admit(piece.len())?;
bytes = bytes.checked_add(piece.len()).ok_or_else(|| {
HawDBError::Execution("regexp_replace() output size overflow".into())
})?;
Ok(())
})?;
let mut output = String::new();
output.try_reserve_exact(bytes).map_err(|_| {
HawDBError::Execution("regexp_replace() output allocation failed".into())
})?;
replacement_pieces(regex, values[0], values[2], global, |piece| {
output.push_str(piece);
Ok(())
})?;
Ok(Value::String(output))
})
}
}
fn replacement_pieces(
regex: ®ex::Regex,
input: &str,
replacement: &str,
global: bool,
mut append: impl FnMut(&str) -> Result<()>,
) -> Result<()> {
let mut cursor = 0;
for captures in regex.captures_iter(input) {
let found = captures.get(0).expect("regex capture zero always exists");
append(&input[cursor..found.start()])?;
let mut escaped = replacement.char_indices();
let mut literal_start = 0;
while let Some((index, ch)) = escaped.next() {
if ch != '\\' {
continue;
}
append(&replacement[literal_start..index])?;
let (offset, escape) = escaped.next().expect("replacement escapes were validated");
match escape {
'\\' => append("\\")?,
'&' => append(found.as_str())?,
'1'..='9' => {
if let Some(group) = captures.get((escape as u8 - b'0') as usize) {
append(group.as_str())?;
}
}
_ => unreachable!("replacement escapes were validated"),
}
literal_start = offset + escape.len_utf8();
}
append(&replacement[literal_start..])?;
cursor = found.end();
if !global {
break;
}
}
append(&input[cursor..])
}
impl crate::RetainedHeapBytes for StringFunction {
fn retained_heap_bytes(&self) -> Option<usize> {
Some(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn replace(input: &str, pattern: &str, replacement: &str, flags: &str) -> Result<Value> {
let values = [input, pattern, replacement, flags].map(|v| Value::String(v.into()));
StringFunction::RegexpReplace.apply(&values.iter().collect::<Vec<_>>(), |_| Ok(()))
}
#[test]
fn regexp_replace_keeps_unicode_captures_literal_dollars_and_empty_matches() {
assert_eq!(
replace("α-42 β-7", r"(\p{L})-(\d+)", r"\2:\1:$1:\&:\\", "g").unwrap(),
Value::String("42:α:$1:α-42:\\ 7:β:$1:β-7:\\".into())
);
assert_eq!(
replace("αβ", "", "-", "g").unwrap(),
Value::String("-α-β-".into())
);
assert_eq!(
replace("a a", "a", "b", "").unwrap(),
Value::String("b a".into())
);
}
#[test]
fn invalid_replacement_is_rejected_even_without_a_match() {
assert!(replace("unmatched", "x", r"\q", "g").is_err());
assert!(replace("unmatched", "x", "\\", "g").is_err());
}
#[test]
fn output_is_admitted_before_each_allocation_and_refusal_is_terminal() {
let values = [
Value::String("aa".into()),
Value::String("a".into()),
Value::String("expanded".into()),
Value::String("g".into()),
];
let mut admitted = 0;
let result =
StringFunction::RegexpReplace.apply(&values.iter().collect::<Vec<_>>(), |bytes| {
admitted += bytes;
if admitted > 8 {
Err(HawDBError::Execution("test output budget exhausted".into()))
} else {
Ok(())
}
});
assert!(result
.unwrap_err()
.to_string()
.contains("test output budget exhausted"));
assert_eq!(admitted, 16);
}
}
#[cfg(test)]
mod prepared_tests {
use super::*;
#[test]
fn invariant_regex_is_compiled_once_and_execution_does_not_mutate_cache_owner() {
use crate::RetainedHeapBytes;
let before = crate::regex::compilation_count();
let mut cached = ValidatedRegex::new(" ").unwrap();
assert_eq!(crate::regex::compilation_count() - before, 1);
cached.retain_source_only();
let cached_bytes = cached.retained_heap_bytes();
let mut execution = cached.clone();
execution.prepare_for_execution().unwrap();
let prepared = crate::regex::compilation_count();
for (input, expected) in [("A B", "A_B"), ("α β", "α_β"), ("none", "none")] {
let values = [input, " ", "_", "g"].map(|value| Value::String(value.into()));
let result = StringFunction::RegexpReplace
.apply_prepared(&values.iter().collect::<Vec<_>>(), Some(&execution), |_| {
Ok(())
})
.unwrap();
assert_eq!(result, Value::String(expected.into()));
}
assert_eq!(crate::regex::compilation_count(), prepared);
assert_eq!(cached.retained_heap_bytes(), cached_bytes);
assert!(cached_bytes.is_some());
assert!(execution.retained_heap_bytes().is_none());
}
#[test]
fn many_matches_admit_all_output_before_materialization() {
let values = ["aaaa", "a", "1234", "g"].map(|value| Value::String(value.into()));
let mut admissions = Vec::new();
let output = StringFunction::RegexpReplace
.apply(&values.iter().collect::<Vec<_>>(), |bytes| {
admissions.push(bytes);
Ok(())
})
.unwrap();
assert_eq!(output, Value::String("1234123412341234".into()));
assert_eq!(admissions.iter().sum::<usize>(), 16);
}
}