//! 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), Object(BTreeMap), Component { name: String, args: Vec }, Action(Arc), /// A `$var` passed straight to a component, so inputs can bind to it, /// with its current value. State(String, Box), /// 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, locals: Vec<(String, Value)>, } #[derive(Debug, PartialEq)] pub enum Step { Set(String, Expr), Reset(Vec), 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 { 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::>().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`. 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; /// 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 { 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>, /// Statements being evaluated, to catch cycles. stack: RefCell>, /// `@Each` loop variables in scope, innermost last. locals: RefCell>, steps: RefCell, } 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 = 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::>()); 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::() / 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::() { 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::() == 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})); } }