//! Evaluates an OpenUI Lang program into a tree of values for the renderer,
//! given the current `$state`. Programs are small, so the whole program is
//! evaluated on every render; each statement once, then reused.
//!
//! Evaluation never fails as a whole: a reference that isn't defined yet
//! (the answer is still streaming) is `Pending`, and anything wrong is an
//! `Error` value in its place, so the rest still renders.
use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use super::lang::{BinaryOp, Expr, Program, UnaryOp};
/// How deep references and nesting may go before it counts as a cycle.
const MAX_DEPTH: usize = 64;
/// How many expressions one evaluation may visit. Statements are cached, so
/// only `@Each` over large arrays gets near this.
const MAX_STEPS: usize = 200_000;
#[derive(Clone, Debug, PartialEq)]
pub enum Value {
Null,
Bool(bool),
Number(f64),
String(String),
Array(Vec<Value>),
Object(BTreeMap<String, Value>),
Component { name: String, args: Vec<Value> },
Action(Arc<Action>),
/// A `$var` passed straight to a component, so inputs can bind to it,
/// with its current value.
State(String, Box<Value>),
/// Not defined yet: the program is still streaming in.
Pending,
Error(String),
}
/// `Action([steps])`. Step arguments are evaluated when the action runs, so
/// `@Set($n, $n + 1)` sees the state at that moment; the loop variables of
/// the `@Each` it was built in come along.
#[derive(Debug, PartialEq)]
pub struct Action {
pub steps: Vec<Step>,
locals: Vec<(String, Value)>,
}
#[derive(Debug, PartialEq)]
pub enum Step {
Set(String, Expr),
Reset(Vec<String>),
ToAssistant(Expr),
OpenUrl(Expr),
/// `@Run` and anything else this app can't do.
Unsupported(String),
}
/// What running an action asks of the app, beyond changing state.
#[derive(Debug, PartialEq)]
pub enum Effect {
ToAssistant(String),
OpenUrl(String),
Unsupported(String),
}
impl Value {
/// The value itself, seeing through a `$var` binding.
pub fn get(&self) -> &Value {
match self {
Value::State(_, value) => value.get(),
value => value,
}
}
pub fn as_str(&self) -> Option<&str> {
match self.get() {
Value::String(text) => Some(text),
_ => None,
}
}
pub fn as_number(&self) -> Option<f64> {
match self.get() {
Value::Number(number) => Some(*number),
Value::String(text) => text.trim().parse().ok(),
Value::Bool(value) => Some(if *value { 1. } else { 0. }),
_ => None,
}
}
pub fn as_array(&self) -> Option<&[Value]> {
match self.get() {
Value::Array(items) => Some(items),
_ => None,
}
}
/// Truthiness as in JS: empty strings, zero, null and false are false.
pub fn truthy(&self) -> bool {
match self.get() {
Value::Null | Value::Pending | Value::Error(_) => false,
Value::Bool(value) => *value,
Value::Number(number) => *number != 0. && !number.is_nan(),
Value::String(text) => !text.is_empty(),
_ => true,
}
}
/// The value as text for display and string concatenation. `null`
/// shows as nothing, since models use it as "no value".
pub fn text(&self) -> String {
match self.get() {
Value::Null | Value::Pending | Value::Component { .. } | Value::Action(_) => String::new(),
Value::Bool(value) => value.to_string(),
Value::Number(number) => format_number(*number),
Value::String(text) => text.clone(),
Value::Array(items) => items.iter().map(Value::text).collect::<Vec<_>>().join(", "),
Value::Object(_) => self.to_json().to_string(),
Value::Error(error) => error.clone(),
Value::State(..) => unreachable!("`get` sees through state"),
}
}
pub fn to_json(&self) -> serde_json::Value {
use serde_json::Value as Json;
match self.get() {
Value::Null | Value::Pending | Value::Component { .. } | Value::Action(_) | Value::Error(_) => Json::Null,
Value::Bool(value) => Json::Bool(*value),
Value::Number(number) => serde_json::Number::from_f64(*number)
.map(|number| match number.as_f64() {
Some(float) if float.fract() == 0. && float.abs() < 9e15 => Json::from(float as i64),
_ => Json::Number(number),
})
.unwrap_or(Json::Null),
Value::String(text) => Json::String(text.clone()),
Value::Array(items) => Json::Array(items.iter().map(Value::to_json).collect()),
Value::Object(fields) => Json::Object(fields.iter().map(|(key, value)| (key.clone(), value.to_json())).collect()),
Value::State(..) => unreachable!("`get` sees through state"),
}
}
/// Whether this or anything inside is still `Pending`.
#[cfg(test)]
pub fn is_pending(&self) -> bool {
match self {
Value::Pending => true,
Value::State(_, value) => value.is_pending(),
Value::Array(items) | Value::Component { args: items, .. } => items.iter().any(Value::is_pending),
Value::Object(fields) => fields.values().any(Value::is_pending),
_ => false,
}
}
}
/// Integers without a decimal point, other numbers as short as they
/// round-trip.
pub fn format_number(number: f64) -> String {
if number.fract() == 0. && number.abs() < 1e15 {
format!("{}", number as i64)
} else {
format!("{number}")
}
}
/// `$state` values the user has changed. Variables not in here have their
/// declared default.
pub type State = BTreeMap<String, Value>;
/// Evaluates the program's root.
pub fn evaluate(program: &Program, state: &State) -> Value {
let Some(root) = program.root() else {
return Value::Pending;
};
Evaluator::new(program, state).reference(&root.name)
}
/// Runs an action's steps in order against `state`, returning what else it
/// asks the app to do.
pub fn run(program: &Program, state: &mut State, action: &Action) -> Vec<Effect> {
let mut effects = Vec::new();
for step in &action.steps {
// Each step sees the state the previous ones left.
let snapshot = state.clone();
let evaluator = Evaluator::new(program, &snapshot);
*evaluator.locals.borrow_mut() = action.locals.clone();
match step {
Step::Set(name, expr) => {
let value = evaluator.eval(expr, 0).get().clone();
state.insert(name.clone(), value);
}
Step::Reset(names) => {
for name in names {
state.remove(name);
}
}
Step::ToAssistant(expr) => effects.push(Effect::ToAssistant(evaluator.eval(expr, 0).text())),
Step::OpenUrl(expr) => effects.push(Effect::OpenUrl(evaluator.eval(expr, 0).text())),
Step::Unsupported(name) => effects.push(Effect::Unsupported(name.clone())),
}
}
effects
}
/// The value of `$name` now: the user's, else the declared default.
pub fn state_value(program: &Program, state: &State, name: &str) -> Value {
Evaluator::new(program, state).state(name, 0)
}
struct Evaluator<'a> {
program: &'a Program,
state: &'a State,
statements: HashMap<&'a str, &'a Expr>,
cache: RefCell<HashMap<String, Value>>,
/// Statements being evaluated, to catch cycles.
stack: RefCell<Vec<String>>,
/// `@Each` loop variables in scope, innermost last.
locals: RefCell<Vec<(String, Value)>>,
steps: RefCell<usize>,
}
impl<'a> Evaluator<'a> {
fn new(program: &'a Program, state: &'a State) -> Self {
let statements = program
.statements
.iter()
.filter(|statement| !statement.state)
.map(|statement| (statement.name.as_str(), &statement.expr))
.collect();
Self {
program,
state,
statements,
cache: RefCell::default(),
stack: RefCell::default(),
locals: RefCell::default(),
steps: RefCell::new(0),
}
}
fn reference(&self, name: &str) -> Value {
if let Some((_, value)) = self.locals.borrow().iter().rev().find(|(local, _)| local == name) {
return value.clone();
}
if let Some(value) = self.cache.borrow().get(name) {
return value.clone();
}
let Some(expr) = self.statements.get(name) else {
return Value::Pending;
};
if self.stack.borrow().iter().any(|open| open == name) {
return Value::Error(format!("`{name}` refers to itself"));
}
if self.stack.borrow().len() >= MAX_DEPTH {
return Value::Error("Nested too deeply".into());
}
self.stack.borrow_mut().push(name.to_string());
// Statements don't see loop variables; those are only for the
// template written inline in `@Each`.
let locals = std::mem::take(&mut *self.locals.borrow_mut());
let value = self.eval(expr, 0);
*self.locals.borrow_mut() = locals;
self.stack.borrow_mut().pop();
self.cache.borrow_mut().insert(name.to_string(), value.clone());
value
}
fn state(&self, name: &str, depth: usize) -> Value {
if let Some(value) = self.state.get(name) {
return value.clone();
}
match self.program.state_default(name) {
Some(expr) => self.eval(expr, depth + 1).get().clone(),
None => Value::Null,
}
}
fn eval(&self, expr: &Expr, depth: usize) -> Value {
if depth > MAX_DEPTH {
return Value::Error("Nested too deeply".into());
}
{
let mut steps = self.steps.borrow_mut();
*steps += 1;
if *steps > MAX_STEPS {
return Value::Error("Too much to evaluate".into());
}
}
let depth = depth + 1;
match expr {
Expr::Null => Value::Null,
Expr::Bool(value) => Value::Bool(*value),
Expr::Number(number) => Value::Number(*number),
Expr::String(text) => Value::String(text.clone()),
Expr::Array(items) => Value::Array(items.iter().map(|item| self.eval(item, depth)).collect()),
Expr::Object(fields) => {
Value::Object(fields.iter().map(|(key, value)| (key.clone(), self.eval(value, depth).get().clone())).collect())
}
Expr::Ref(name) => self.reference(name),
Expr::State(name) => self.state(name, depth),
Expr::Call(name, args) => match name.as_str() {
"Action" => self.action(args),
_ => Value::Component {
name: name.clone(),
args: args
.iter()
.map(|arg| match arg {
// Inputs bind to a `$var` handed to them directly.
Expr::State(var) => Value::State(var.clone(), Box::new(self.state(var, depth))),
arg => self.eval(arg, depth),
})
.collect(),
},
},
Expr::Builtin(name, args) => self.builtin(name, args, depth),
Expr::Member(target, field) => member(self.eval(target, depth).get(), field),
Expr::Unary(op, operand) => {
let value = self.eval(operand, depth);
match (op, value.get()) {
(_, Value::Pending) => Value::Pending,
(_, Value::Error(error)) => Value::Error(error.clone()),
(UnaryOp::Not, value) => Value::Bool(!value.truthy()),
(UnaryOp::Neg, value) => match value.as_number() {
Some(number) => Value::Number(-number),
None => Value::Error(format!("Can't negate {}", describe(value))),
},
}
}
Expr::Binary(BinaryOp::And, left, right) => {
let left = self.eval(left, depth);
if left.get() == &Value::Pending || !left.truthy() { left } else { self.eval(right, depth) }
}
Expr::Binary(BinaryOp::Or, left, right) => {
let left = self.eval(left, depth);
if left.get() == &Value::Pending || left.truthy() { left } else { self.eval(right, depth) }
}
Expr::Binary(op, left, right) => binary(*op, self.eval(left, depth).get(), self.eval(right, depth).get()),
Expr::Ternary(condition, then, otherwise) => {
let condition = self.eval(condition, depth);
match condition.get() {
Value::Pending => Value::Pending,
Value::Error(error) => Value::Error(error.clone()),
value if value.truthy() => self.eval(then, depth),
_ => self.eval(otherwise, depth),
}
}
}
}
fn action(&self, args: &[Expr]) -> Value {
let steps = match args.first() {
Some(Expr::Array(items)) => items.iter().map(step).collect(),
Some(single) => vec![step(single)],
None => Vec::new(),
};
Value::Action(Arc::new(Action { steps, locals: self.locals.borrow().clone() }))
}
fn builtin(&self, name: &str, args: &[Expr], depth: usize) -> Value {
// Action steps used on their own, as `Button("Go", @ToAssistant("go"))`.
if matches!(name, "Set" | "Reset" | "ToAssistant" | "OpenUrl" | "Run") {
return Value::Action(Arc::new(Action {
steps: vec![step(&Expr::Builtin(name.to_string(), args.to_vec()))],
locals: self.locals.borrow().clone(),
}));
}
if name == "Each" {
return self.each(args, depth);
}
let values: Vec<Value> = args.iter().map(|arg| self.eval(arg, depth).get().clone()).collect();
if values.iter().any(|value| value == &Value::Pending) {
return Value::Pending;
}
if let Some(error) = values.iter().find_map(|value| match value {
Value::Error(error) => Some(error.clone()),
_ => None,
}) {
return Value::Error(error);
}
let arg = |ix: usize| values.get(ix).unwrap_or(&Value::Null);
let array = || arg(0).as_array().map(<[Value]>::to_vec);
let numbers = || array().map(|items| items.iter().filter_map(Value::as_number).collect::<Vec<f64>>());
let number = || arg(0).as_number();
let need = |what: &str| Value::Error(format!("@{name} needs {what}"));
match name {
"Count" => match arg(0) {
Value::Array(items) => Value::Number(items.len() as f64),
Value::String(text) => Value::Number(text.chars().count() as f64),
Value::Null => Value::Number(0.),
_ => need("an array"),
},
"Sum" => numbers().map(|numbers| Value::Number(numbers.iter().sum())).unwrap_or_else(|| need("an array")),
"Avg" => match numbers() {
Some(numbers) if numbers.is_empty() => Value::Null,
Some(numbers) => Value::Number(numbers.iter().sum::<f64>() / numbers.len() as f64),
None => need("an array"),
},
"Min" => match numbers() {
Some(numbers) => numbers.into_iter().reduce(f64::min).map(Value::Number).unwrap_or(Value::Null),
None => need("an array"),
},
"Max" => match numbers() {
Some(numbers) => numbers.into_iter().reduce(f64::max).map(Value::Number).unwrap_or(Value::Null),
None => need("an array"),
},
"First" => array().map(|items| items.first().cloned().unwrap_or(Value::Null)).unwrap_or_else(|| need("an array")),
"Last" => array().map(|items| items.last().cloned().unwrap_or(Value::Null)).unwrap_or_else(|| need("an array")),
"Filter" => {
let Some(items) = array() else { return need("an array") };
let field = arg(1).text();
let op = arg(2).text();
let wanted = arg(3);
let mut kept = Vec::new();
for item in items {
let value = member(&item, &field);
let keep = match op.as_str() {
"contains" => value.text().to_lowercase().contains(&wanted.text().to_lowercase()),
"==" => equals(&value, wanted),
"!=" => !equals(&value, wanted),
">" | "<" | ">=" | "<=" => {
let op = match op.as_str() {
">" => BinaryOp::Gt,
"<" => BinaryOp::Lt,
">=" => BinaryOp::Ge,
_ => BinaryOp::Le,
};
binary(op, &value, wanted).truthy()
}
_ => return Value::Error(format!("@Filter doesn't know the operator {op:?}")),
};
if keep {
kept.push(item);
}
}
Value::Array(kept)
}
"Sort" => {
let Some(mut items) = array() else { return need("an array") };
let field = arg(1).text();
let descending = arg(2).text().eq_ignore_ascii_case("desc");
items.sort_by(|a, b| {
let (a, b) = if field.is_empty() { (a.clone(), b.clone()) } else { (member(a, &field), member(b, &field)) };
let order = match (a.as_number(), b.as_number(), &a, &b) {
(_, _, Value::String(a), Value::String(b)) => a.cmp(b),
(Some(a), Some(b), _, _) => a.total_cmp(&b),
_ => a.text().cmp(&b.text()),
};
if descending { order.reverse() } else { order }
});
Value::Array(items)
}
"Round" => match number() {
Some(value) => {
let factor = 10f64.powi(arg(1).as_number().unwrap_or(0.).clamp(0., 12.) as i32);
Value::Number((value * factor).round() / factor)
}
None => need("a number"),
},
"Abs" => number().map(|value| Value::Number(value.abs())).unwrap_or_else(|| need("a number")),
"Floor" => number().map(|value| Value::Number(value.floor())).unwrap_or_else(|| need("a number")),
"Ceil" => number().map(|value| Value::Number(value.ceil())).unwrap_or_else(|| need("a number")),
_ => Value::Error(format!("Unknown function @{name}")),
}
}
/// `@Each(array, "item", template)`: the template once per element, with
/// the element bound to the name.
fn each(&self, args: &[Expr], depth: usize) -> Value {
let [array, name, template] = args else {
return Value::Error("@Each needs an array, a name and a template".into());
};
let array = self.eval(array, depth);
let items = match array.get() {
Value::Pending => return Value::Pending,
Value::Error(error) => return Value::Error(error.clone()),
Value::Null => return Value::Array(Vec::new()),
Value::Array(items) => items.clone(),
other => vec![other.clone()],
};
// The name is a string, but models also write it bare.
let name = match name {
Expr::String(name) | Expr::Ref(name) => name.clone(),
_ => return Value::Error("@Each needs a name for the loop variable".into()),
};
Value::Array(
items
.into_iter()
.map(|item| {
self.locals.borrow_mut().push((name.clone(), item));
let value = self.eval(template, depth);
self.locals.borrow_mut().pop();
value
})
.collect(),
)
}
}
/// An action step from its expression; anything but a known step builtin
/// is unsupported.
fn step(expr: &Expr) -> Step {
let Expr::Builtin(name, args) = expr else {
return Step::Unsupported("a step that isn't a @function".into());
};
let state_names = || {
args.iter()
.filter_map(|arg| match arg {
Expr::State(name) | Expr::String(name) => Some(name.trim_start_matches('$').to_string()),
_ => None,
})
.collect()
};
match (name.as_str(), args.as_slice()) {
("Set", [Expr::State(var), value]) => Step::Set(var.clone(), value.clone()),
("Reset", _) => Step::Reset(state_names()),
("ToAssistant", [message, ..]) => Step::ToAssistant(message.clone()),
("OpenUrl", [url, ..]) => Step::OpenUrl(url.clone()),
_ => Step::Unsupported(format!("@{name}")),
}
}
/// `value.field`: a field of an object, the field of every element of an
/// array, or an index into an array.
fn member(value: &Value, field: &str) -> Value {
match value.get() {
Value::Pending => Value::Pending,
Value::Error(error) => Value::Error(error.clone()),
Value::Object(fields) => fields.get(field).cloned().unwrap_or(Value::Null),
Value::Array(items) => match field.parse::<usize>() {
Ok(index) => items.get(index).cloned().unwrap_or(Value::Null),
Err(_) if field == "length" => Value::Number(items.len() as f64),
Err(_) => Value::Array(items.iter().map(|item| member(item, field)).collect()),
},
Value::String(text) if field == "length" => Value::Number(text.chars().count() as f64),
_ => Value::Null,
}
}
fn binary(op: BinaryOp, left: &Value, right: &Value) -> Value {
match (left, right) {
(Value::Pending, _) | (_, Value::Pending) => return Value::Pending,
(Value::Error(error), _) | (_, Value::Error(error)) => return Value::Error(error.clone()),
_ => {}
}
let numbers = || match (left.as_number(), right.as_number()) {
(Some(a), Some(b)) if !matches!(left, Value::Null | Value::Array(_)) => Some((a, b)),
_ => None,
};
match op {
BinaryOp::Add => match (left, right) {
(Value::String(_), _) | (_, Value::String(_)) => Value::String(left.text() + &right.text()),
(Value::Number(a), Value::Number(b)) => Value::Number(a + b),
(Value::Array(a), Value::Array(b)) => Value::Array(a.iter().chain(b).cloned().collect()),
_ => match numbers() {
Some((a, b)) => Value::Number(a + b),
None => Value::Error(format!("Can't add {} and {}", describe(left), describe(right))),
},
},
BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Rem => {
let Some((a, b)) = numbers() else {
return Value::Error(format!("Can't do arithmetic on {} and {}", describe(left), describe(right)));
};
Value::Number(match op {
BinaryOp::Sub => a - b,
BinaryOp::Mul => a * b,
BinaryOp::Div => a / b,
_ => a % b,
})
}
BinaryOp::Eq => Value::Bool(equals(left, right)),
BinaryOp::Ne => Value::Bool(!equals(left, right)),
BinaryOp::Gt | BinaryOp::Lt | BinaryOp::Ge | BinaryOp::Le => {
let order = match (left, right) {
(Value::String(a), Value::String(b)) => Some(a.cmp(b)),
_ => numbers().and_then(|(a, b)| a.partial_cmp(&b)),
};
Value::Bool(order.is_some_and(|order| match op {
BinaryOp::Gt => order.is_gt(),
BinaryOp::Lt => order.is_lt(),
BinaryOp::Ge => order.is_ge(),
_ => order.is_le(),
}))
}
BinaryOp::And | BinaryOp::Or => unreachable!("short-circuited in `eval`"),
}
}
/// Equality that lets a number equal its text, since select and input
/// values arrive as strings.
fn equals(left: &Value, right: &Value) -> bool {
match (left.get(), right.get()) {
(Value::Number(a), Value::String(b)) | (Value::String(b), Value::Number(a)) => b.trim().parse::<f64>() == Ok(*a),
(a, b) => a == b,
}
}
fn describe(value: &Value) -> &'static str {
match value.get() {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
Value::Component { .. } => "a component",
Value::Action(_) => "an action",
Value::Pending => "a pending value",
Value::Error(_) => "an error",
Value::State(..) => "state",
}
}
#[cfg(test)]
mod tests {
use super::super::lang::parse;
use super::{Effect, State, Value, evaluate, run, state_value};
fn eval(source: &str) -> Value {
let program = parse(source);
assert!(program.errors.is_empty(), "{source}: {:?}", program.errors);
evaluate(&program, &State::new())
}
fn n(number: f64) -> Value {
Value::Number(number)
}
fn s(text: &str) -> Value {
Value::String(text.into())
}
#[::core::prelude::v1::test]
fn arithmetic_and_logic() {
assert_eq!(eval("x = 1 + 2 * 3"), n(7.));
assert_eq!(eval("x = 10 % 4 - -1"), n(3.));
assert_eq!(eval(r#"x = "" + 7 + " days""#), s("7 days"));
assert_eq!(eval(r#"x = 1 + 2 + "a""#), s("3a"));
assert_eq!(eval("x = 2.5 * 2 == 5 && 3 > 2"), Value::Bool(true));
assert_eq!(eval(r#"x = null || "fallback""#), s("fallback"));
assert_eq!(eval(r#"x = "7" == 7"#), Value::Bool(true));
assert_eq!(eval(r#"x = "b" > "a" ? "yes" : "no""#), s("yes"));
assert!(matches!(eval(r#"x = {a: 1} - 1"#), Value::Error(_)));
assert_eq!(super::format_number(0.1 + 0.2), "0.30000000000000004");
assert_eq!(eval("x = 1.5").text(), "1.5");
assert_eq!(eval("x = 3").text(), "3");
}
#[::core::prelude::v1::test]
fn forward_references_and_pluck() {
let value = eval(
r#"root = Table([Col("Title", data.rows.title), Col("Total", @Sum(data.rows.n))])
data = {rows: [{title: "a", n: 2}, {title: "b", n: 3}]}"#,
);
let Value::Component { name, args } = value else { panic!("{value:?}") };
assert_eq!(name, "Table");
let Value::Array(columns) = &args[0] else { panic!() };
assert_eq!(
columns[0],
Value::Component { name: "Col".into(), args: vec![s("Title"), Value::Array(vec![s("a"), s("b")])] }
);
assert_eq!(columns[1], Value::Component { name: "Col".into(), args: vec![s("Total"), n(5.)] });
assert_eq!(eval("x = [[1, 2], [3]].0.1"), n(2.));
assert_eq!(eval("x = [1, 2, 3].length"), n(3.));
}
#[::core::prelude::v1::test]
fn pending_and_cycles() {
assert_eq!(eval("root = Stack([later])"), Value::Component { name: "Stack".into(), args: vec![Value::Array(vec![Value::Pending])] });
assert!(eval("root = Stack([later])").is_pending());
assert_eq!(eval("x = missing + 1"), Value::Pending);
let value = eval("a = Stack([b])\nb = Stack([a])");
let text = format!("{value:?}");
assert!(text.contains("refers to itself"), "{text}");
let value = eval("a = a + 1");
assert!(matches!(value, Value::Error(_)), "{value:?}");
// A long chain hits the depth limit instead of the stack.
let chain: String = (0..200).map(|ix| format!("s{ix} = s{}\n", ix + 1)).collect();
assert!(matches!(eval(&chain), Value::Error(_)));
}
#[::core::prelude::v1::test]
fn builtins() {
let data = r#"rows = [{name: "b", n: 3}, {name: "a", n: 1}, {name: "c", n: 2}]"#;
let with = |expr: &str| eval(&format!("x = {expr}\n{data}"));
assert_eq!(with("@Count(rows)"), n(3.));
assert_eq!(with("@Sum(rows.n)"), n(6.));
assert_eq!(with("@Avg(rows.n)"), n(2.));
assert_eq!(with("@Min(rows.n)"), n(1.));
assert_eq!(with("@Max(rows.n)"), n(3.));
assert_eq!(with("@First(rows).name"), s("b"));
assert_eq!(with("@Last(rows.name)"), s("c"));
assert_eq!(with(r#"@Sort(rows, "n").name"#), Value::Array(vec![s("a"), s("c"), s("b")]));
assert_eq!(with(r#"@Sort(rows, "name", "desc").name"#), Value::Array(vec![s("c"), s("b"), s("a")]));
assert_eq!(with(r#"@Filter(rows, "n", ">=", 2).name"#), Value::Array(vec![s("b"), s("c")]));
assert_eq!(with(r#"@Filter(rows, "name", "contains", "A").n"#), Value::Array(vec![n(1.)]));
assert_eq!(with(r#"@Filter(rows, "n", "==", "3").name"#), Value::Array(vec![s("b")]));
assert_eq!(eval("x = @Round(3.14159, 2)"), n(3.14));
assert_eq!(eval("x = @Round(2.5)"), n(3.));
assert_eq!(eval("x = @Abs(-2) + @Floor(1.7) + @Ceil(1.2)"), n(5.));
assert!(matches!(eval("x = @Nope(1)"), Value::Error(_)));
assert_eq!(eval("x = @Sum(later)"), Value::Pending);
}
#[::core::prelude::v1::test]
fn each() {
let value = eval(r#"root = @Each(["a", "b"], "item", Tag(item + "!"))"#);
assert_eq!(
value,
Value::Array(vec![
Value::Component { name: "Tag".into(), args: vec![s("a!")] },
Value::Component { name: "Tag".into(), args: vec![s("b!")] },
])
);
// Loop variables don't leak into other statements.
let value = eval(r#"root = @Each([1], "item", other)
other = item"#);
assert_eq!(value, Value::Array(vec![Value::Pending]));
}
#[::core::prelude::v1::test]
fn state_and_actions() {
let program = parse(
r#"root = Stack([TextContent("Count: " + $count), Input($name), plus, reset])
$count = 0
$name = "Ada"
plus = Button("+", Action([@Set($count, $count + 1), @Set($count, $count + 1), @ToAssistant("now " + $count)]))
reset = Button("Reset", Action([@Reset($count, $name), @OpenUrl("https://example.com"), @Run(query)]))"#,
);
assert!(program.errors.is_empty(), "{:?}", program.errors);
let mut state = State::new();
let Value::Component { args, .. } = evaluate(&program, &state) else { panic!() };
let Value::Array(children) = &args[0] else { panic!() };
assert_eq!(children[0], Value::Component { name: "TextContent".into(), args: vec![s("Count: 0")] });
assert_eq!(children[1], Value::Component { name: "Input".into(), args: vec![Value::State("name".into(), Box::new(s("Ada")))] });
let action = |child: &Value| match child {
Value::Component { args, .. } => match &args[1] {
Value::Action(action) => action.clone(),
other => panic!("{other:?}"),
},
other => panic!("{other:?}"),
};
let effects = run(&program, &mut state, &action(&children[2]));
assert_eq!(state_value(&program, &state, "count"), n(2.));
assert_eq!(effects, vec![Effect::ToAssistant("now 2".into())]);
state.insert("name".into(), s("Grace"));
let effects = run(&program, &mut state, &action(&children[3]));
assert!(state.is_empty());
assert_eq!(state_value(&program, &state, "name"), s("Ada"));
assert_eq!(effects, vec![Effect::OpenUrl("https://example.com".into()), Effect::Unsupported("@Run".into())]);
}
#[::core::prelude::v1::test]
fn actions_keep_loop_variables() {
let program = parse(r#"root = @Each(["x", "y"], "item", Button(item, @ToAssistant("picked " + item)))"#);
let Value::Array(buttons) = evaluate(&program, &State::new()) else { panic!() };
let Value::Component { args, .. } = &buttons[1] else { panic!() };
let Value::Action(action) = &args[1] else { panic!() };
assert_eq!(run(&program, &mut State::new(), action), vec![Effect::ToAssistant("picked y".into())]);
}
#[::core::prelude::v1::test]
fn errors_stay_local() {
let value = eval(r#"root = Stack([TextContent("ok"), TextContent(1 - "x")])"#);
let Value::Component { args, .. } = value else { panic!() };
let Value::Array(children) = &args[0] else { panic!() };
assert_eq!(children[0], Value::Component { name: "TextContent".into(), args: vec![s("ok")] });
assert!(matches!(&children[1], Value::Component { args, .. } if matches!(args[0], Value::Error(_))));
assert_eq!(Value::Object([("a".to_string(), n(1.))].into()).to_json(), serde_json::json!({"a": 1}));
}
}