use super::*; pub(super) fn resolve(value: &Value, data: &Value) -> Result { resolve_at(value, data, data) } pub(super) fn resolve_at(value: &Value, data: &Value, context: &Value) -> Result { if let Some(path) = value .as_object() .and_then(|value| value.get("path")) .and_then(Value::as_str) { let source = if path.starts_with('/') { data } else { context }; let pointer = if path.starts_with('/') { normalize_pointer(path).to_owned() } else { format!("/{path}") }; return Ok(source.pointer(&pointer).cloned().unwrap_or(Value::Null)); } if let Some(call) = value .as_object() .and_then(|value| value.get("call")) .and_then(Value::as_str) { return evaluate( call, value.get("args").unwrap_or(&Value::Null), data, context, ); } match value { Value::Array(values) => values .iter() .map(|value| resolve_at(value, data, context)) .collect::, _>>() .map(Value::Array), Value::Object(values) => values .iter() .map(|(key, value)| Ok((key.clone(), resolve_at(value, data, context)?))) .collect::, String>>() .map(Value::Object), _ => Ok(value.clone()), } } pub(super) fn evaluate( call: &str, args: &Value, data: &Value, context: &Value, ) -> Result { if !FUNCTIONS.contains(&call) { return Err(format!("function `{call}` is not declared by the catalog")); } let args = args .as_object() .ok_or_else(|| format!("function `{call}` requires object args"))?; let value = || resolve_at(args.get("value").unwrap_or(&Value::Null), data, context); match call { "required" => Ok(Value::Bool(match value()? { Value::Null => false, Value::String(value) => !value.is_empty(), Value::Array(value) => !value.is_empty(), Value::Object(value) => !value.is_empty(), _ => true, })), "regex" => { let pattern = args .get("pattern") .and_then(Value::as_str) .ok_or_else(|| "regex requires a pattern".to_owned())?; let regex = Regex::new(pattern).map_err(|error| format!("invalid regex: {error}"))?; Ok(Value::Bool(regex.is_match(&display_value(&value()?)))) } "length" => { let length = display_value(&value()?).chars().count() as u64; let min = args.get("min").and_then(Value::as_u64).unwrap_or(0); let max = args.get("max").and_then(Value::as_u64).unwrap_or(u64::MAX); Ok(Value::Bool((min..=max).contains(&length))) } "numeric" => { let resolved = value()?; let number = resolved .as_f64() .or_else(|| resolved.as_str().and_then(|value| value.parse().ok())); let min = args .get("min") .and_then(Value::as_f64) .unwrap_or(f64::NEG_INFINITY); let max = args .get("max") .and_then(Value::as_f64) .unwrap_or(f64::INFINITY); Ok(Value::Bool( number.is_some_and(|number| (min..=max).contains(&number)), )) } "email" => { let email = display_value(&value()?); let valid = Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") .unwrap() .is_match(&email); Ok(Value::Bool(valid)) } "formatString" => Ok(Value::String(interpolate( &display_value(&value()?), data, context, )?)), "formatNumber" => { let number = value()?.as_f64().unwrap_or(0.0); let digits = resolve_at(args.get("decimals").unwrap_or(&json!(2)), data, context)? .as_f64() .map(|value| value.max(0.0) as u64) .unwrap_or(2) .min(12) as usize; let formatted = format!("{number:.digits$}"); Ok(Value::String( if resolve_at( args.get("grouping").unwrap_or(&Value::Bool(true)), data, context, )? .as_bool() .unwrap_or(true) { group_number(&formatted) } else { formatted }, )) } "formatCurrency" => { let number = value()?.as_f64().unwrap_or(0.0); let currency = display_value(&resolve_at( args.get("currency").unwrap_or(&Value::Null), data, context, )?); let digits = resolve_at(args.get("decimals").unwrap_or(&json!(2)), data, context)? .as_f64() .map(|value| value.max(0.0) as u64) .unwrap_or(2) .min(12) as usize; let formatted = format!("{number:.digits$}"); let number = if resolve_at( args.get("grouping").unwrap_or(&Value::Bool(true)), data, context, )? .as_bool() .unwrap_or(true) { group_number(&formatted) } else { formatted }; Ok(Value::String(format!("{currency} {number}"))) } "formatDate" => { let input = display_value(&value()?); let date = time::OffsetDateTime::parse(&input, &time::format_description::well_known::Rfc3339) .map_err(|error| format!("formatDate requires an RFC 3339 value: {error}"))?; let pattern = display_value(&resolve_at( args.get("format").unwrap_or(&Value::Null), data, context, )?); Ok(Value::String(format_date(date, &pattern))) } "pluralize" => { let number = value()?.as_f64().unwrap_or(0.0); let key = if number == 0.0 && args.contains_key("zero") { "zero" } else if number == 1.0 && args.contains_key("one") { "one" } else if number == 2.0 && args.contains_key("two") { "two" } else { "other" }; resolve_at(args.get(key).unwrap_or(&Value::Null), data, context) } "and" | "or" => { let values = args .get("values") .and_then(Value::as_array) .ok_or_else(|| format!("{call} requires array `values`"))?; let values = values .iter() .map(|value| { resolve_at(value, data, context)? .as_bool() .ok_or_else(|| format!("{call} requires boolean values")) }) .collect::, _>>()?; Ok(Value::Bool(if call == "and" { values.into_iter().all(|value| value) } else { values.into_iter().any(|value| value) })) } "not" => { Ok(Value::Bool(!value()?.as_bool().ok_or_else(|| { "not requires a boolean value".to_owned() })?)) } "openUrl" => Ok(Value::Null), "@index" => { let index = TEMPLATE_INDEX .with(Cell::get) .ok_or_else(|| "@index is only available in a list template".to_owned())?; let offset = args .get("offset") .map(|value| resolve_at(value, data, context)) .transpose()? .and_then(|value| value.as_i64()) .unwrap_or(0); Ok(json!(index as i64 + offset)) } _ => unreachable!(), } } fn interpolate(template: &str, data: &Value, context: &Value) -> Result { let mut output = String::new(); let mut offset = 0; while let Some(relative) = template[offset..].find("${") { let start = offset + relative; if start > offset && template.as_bytes()[start - 1] == b'\\' { output.push_str(&template[offset..start - 1]); output.push_str("${"); offset = start + 2; continue; } output.push_str(&template[offset..start]); let end = expression_end(template, start + 2) .ok_or_else(|| "formatString contains an unclosed expression".to_owned())?; output.push_str(&display_value(&evaluate_expression( &template[start + 2..end], data, context, )?)); offset = end + 1; } output.push_str(&template[offset..]); Ok(output) } fn expression_end(text: &str, mut offset: usize) -> Option { let mut depth = 1; let mut quote = None; while offset < text.len() { let character = text[offset..].chars().next()?; if let Some(current) = quote { if character == current && text.as_bytes().get(offset.wrapping_sub(1)) != Some(&b'\\') { quote = None; } } else if matches!(character, '\'' | '"') { quote = Some(character); } else if text[offset..].starts_with("${") { depth += 1; offset += 2; continue; } else if character == '}' { depth -= 1; if depth == 0 { return Some(offset); } } offset += character.len_utf8(); } None } fn evaluate_expression(expression: &str, data: &Value, context: &Value) -> Result { let expression = expression.trim(); if let Some(open) = expression.find('(') && expression.ends_with(')') { let call = expression[..open].trim(); let mut args = Map::new(); for argument in split_expression_args(&expression[open + 1..expression.len() - 1]) { let colon = top_level_separator(argument, ':') .ok_or_else(|| format!("formatString argument `{argument}` must be named"))?; let name = argument[..colon].trim(); if name.is_empty() { return Err("formatString contains an empty argument name".into()); } args.insert( name.to_owned(), expression_value(argument[colon + 1..].trim(), data, context)?, ); } return evaluate(call, &Value::Object(args), data, context); } let (source, pointer) = if expression.starts_with('/') { (data, normalize_pointer(expression).to_owned()) } else { (context, format!("/{expression}")) }; Ok(source.pointer(&pointer).cloned().unwrap_or(Value::Null)) } fn split_expression_args(input: &str) -> Vec<&str> { let mut arguments = Vec::new(); let mut start = 0; while let Some(relative) = top_level_separator(&input[start..], ',') { arguments.push(input[start..start + relative].trim()); start += relative + 1; } if !input[start..].trim().is_empty() { arguments.push(input[start..].trim()); } arguments } fn top_level_separator(input: &str, separator: char) -> Option { let mut round = 0; let mut braces = 0; let mut quote = None; for (index, character) in input.char_indices() { if let Some(current) = quote { if character == current && input.as_bytes().get(index.wrapping_sub(1)) != Some(&b'\\') { quote = None; } continue; } match character { '\'' | '"' => quote = Some(character), '(' => round += 1, ')' => round -= 1, '{' => braces += 1, '}' => braces -= 1, _ if character == separator && round == 0 && braces == 0 => return Some(index), _ => {} } } None } fn expression_value(expression: &str, data: &Value, context: &Value) -> Result { if expression.starts_with("${") && expression.ends_with('}') { return evaluate_expression(&expression[2..expression.len() - 1], data, context); } if expression.starts_with('\'') && expression.ends_with('\'') && expression.len() >= 2 { return Ok(Value::String( expression[1..expression.len() - 1].to_owned(), )); } if let Ok(value) = serde_json::from_str(expression) { return Ok(value); } if expression.starts_with('/') { return Ok(data .pointer(normalize_pointer(expression)) .cloned() .unwrap_or(Value::Null)); } Ok(Value::String(expression.to_owned())) } fn group_number(number: &str) -> String { let (whole, fraction) = number.split_once('.').unwrap_or((number, "")); let (sign, digits) = whole .strip_prefix('-') .map_or(("", whole), |digits| ("-", digits)); let mut grouped = String::with_capacity(number.len() + number.len() / 3); grouped.push_str(sign); for (index, digit) in digits.chars().enumerate() { if index > 0 && (digits.len() - index).is_multiple_of(3) { grouped.push(','); } grouped.push(digit); } if !fraction.is_empty() { grouped.push('.'); grouped.push_str(fraction); } grouped } pub(super) fn format_date(date: time::OffsetDateTime, pattern: &str) -> String { const MONTHS: [&str; 12] = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; const DAYS: [&str; 7] = [ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", ]; let month = MONTHS[date.month() as usize - 1]; let day = DAYS[date.weekday().number_days_from_monday() as usize]; let hour_12 = match date.hour() % 12 { 0 => 12, hour => hour, }; let replacements = [ ("EEEE", day.to_owned()), ("MMMM", month.to_owned()), ("yyyy", format!("{:04}", date.year())), ("MMM", month[..3].to_owned()), ("yy", format!("{:02}", date.year().rem_euclid(100))), ("MM", format!("{:02}", date.month() as u8)), ("dd", format!("{:02}", date.day())), ("HH", format!("{:02}", date.hour())), ("hh", format!("{hour_12:02}")), ("mm", format!("{:02}", date.minute())), ("ss", format!("{:02}", date.second())), ("E", day[..3].to_owned()), ("M", (date.month() as u8).to_string()), ("d", date.day().to_string()), ("H", date.hour().to_string()), ("h", hour_12.to_string()), ("a", if date.hour() < 12 { "AM" } else { "PM" }.to_owned()), ]; let mut output = String::new(); let mut remaining = pattern; while !remaining.is_empty() { if let Some((token, replacement)) = replacements .iter() .find(|(token, _)| remaining.starts_with(token)) { output.push_str(replacement); remaining = &remaining[token.len()..]; } else { let character = remaining.chars().next().unwrap(); output.push(character); remaining = &remaining[character.len_utf8()..]; } } output }