452 lines
15 KiB
Rust
452 lines
15 KiB
Rust
use super::*;
|
|
|
|
pub(super) fn resolve(value: &Value, data: &Value) -> Result<Value, String> {
|
|
resolve_at(value, data, data)
|
|
}
|
|
|
|
pub(super) fn resolve_at(value: &Value, data: &Value, context: &Value) -> Result<Value, String> {
|
|
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::<Result<Vec<_>, _>>()
|
|
.map(Value::Array),
|
|
Value::Object(values) => values
|
|
.iter()
|
|
.map(|(key, value)| Ok((key.clone(), resolve_at(value, data, context)?)))
|
|
.collect::<Result<Map<_, _>, String>>()
|
|
.map(Value::Object),
|
|
_ => Ok(value.clone()),
|
|
}
|
|
}
|
|
|
|
pub(super) fn evaluate(
|
|
call: &str,
|
|
args: &Value,
|
|
data: &Value,
|
|
context: &Value,
|
|
) -> Result<Value, String> {
|
|
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::<Result<Vec<_>, _>>()?;
|
|
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<String, String> {
|
|
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<usize> {
|
|
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<Value, String> {
|
|
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<usize> {
|
|
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<Value, String> {
|
|
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
|
|
}
|