Refactor application and runtime boundaries
This commit is contained in:
451
src/a2ui/evaluation.rs
Normal file
451
src/a2ui/evaluation.rs
Normal file
@@ -0,0 +1,451 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user