Skip to main content

rustmotion_core/expr/
mod.rs

1mod ast;
2mod builtins;
3mod eval;
4mod lexer;
5mod parser;
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10pub trait Scope {
11    fn var(&self, name: &str) -> Option<f64>;
12
13    fn node_prop(&self, id: &str, prop: &str) -> Option<f64> {
14        let _ = (id, prop);
15        None
16    }
17}
18
19#[derive(Debug, Clone, PartialEq, thiserror::Error)]
20pub enum ExprError {
21    #[error("cannot parse expression `{src}`: {reason}")]
22    Parse { src: String, reason: String },
23    #[error("unknown identifier `{0}`")]
24    UnknownIdent(String),
25    #[error("`{name}` takes {expected} argument(s), got {got}")]
26    Arity {
27        name: String,
28        expected: usize,
29        got: usize,
30    },
31    #[error("expression nests deeper than {0} levels")]
32    TooDeep(usize),
33}
34
35#[derive(Debug, Clone, PartialEq)]
36pub struct Expr {
37    ops: Box<[eval::Op]>,
38    var_names: Box<[Box<str>]>,
39    node_refs: Box<[(Box<str>, Box<str>)]>,
40    free_vars: Vec<String>,
41    is_static: bool,
42}
43
44impl Expr {
45    pub fn parse(src: &str) -> Result<Self, ExprError> {
46        let body = strip_expr_prefix(src);
47        let tree = parser::parse(body)?;
48        let compiled = eval::compile(&tree);
49        Ok(Expr {
50            ops: compiled.ops,
51            var_names: compiled.var_names,
52            node_refs: compiled.node_refs,
53            free_vars: compiled.free_vars,
54            is_static: compiled.is_static,
55        })
56    }
57
58    pub fn eval(&self, scope: &dyn Scope) -> Result<f64, ExprError> {
59        eval::run(&self.ops, &self.var_names, &self.node_refs, scope)
60    }
61
62    pub fn free_vars(&self) -> Vec<String> {
63        self.free_vars.clone()
64    }
65
66    pub fn is_static(&self) -> bool {
67        self.is_static
68    }
69}
70
71fn strip_expr_prefix(src: &str) -> &str {
72    let trimmed = src.trim_start();
73    match trimmed.strip_prefix('=') {
74        Some(rest) => rest.trim_start(),
75        None => trimmed,
76    }
77}
78
79/// A scenario value that is either a plain literal or, prefixed with `=`,
80/// an expression to evaluate.
81///
82/// `#[serde(untagged)]`: deserialization tries `Literal(T)` first, falling
83/// back to `Expr(String)` only when the JSON value doesn't fit `T`. That
84/// ordering is why this works cleanly for the numbers and strictly-typed
85/// colours this issue targets — a JSON string can never satisfy a numeric
86/// `T`, so `"= $W/2"` always falls through to `Expr` — but is a deliberate
87/// non-goal for `Computed<String>`: there, *every* JSON string
88/// (`"= ..."` included) already satisfies `Literal(String)` first and
89/// `Expr` is never reached. Schema fields that want expression support are
90/// expected to use a strictly-typed `T` (a number, or a colour type with
91/// its own validating `Deserialize`), not a bare `String`.
92///
93/// This type carries no evaluation logic of its own — resolving an
94/// `Expr(String)` variant means calling [`Expr::parse`] on its contents and
95/// then [`Expr::eval`] (or folding it statically, see the [`crate::expr`]
96/// module doc) — deliberately, so this crate does not have to hand back an
97/// opinion on *when* that happens; that is a decision for whatever owns the
98/// field (the static-folding pass, or the per-frame engine context).
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
100#[serde(untagged)]
101pub enum Computed<T> {
102    Literal(T),
103    Expr(String),
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use std::f64::consts::{PI, TAU};
110
111    struct MapScope<'a>(&'a [(&'a str, f64)]);
112    impl Scope for MapScope<'_> {
113        fn var(&self, name: &str) -> Option<f64> {
114            self.0.iter().find(|(n, _)| *n == name).map(|(_, v)| *v)
115        }
116    }
117
118    struct EmptyScope;
119    impl Scope for EmptyScope {
120        fn var(&self, _name: &str) -> Option<f64> {
121            None
122        }
123    }
124
125    #[test]
126    fn parses_with_and_without_equals_prefix() {
127        let a = Expr::parse("= 1 + 1").unwrap();
128        let b = Expr::parse("1 + 1").unwrap();
129        assert_eq!(a.eval(&EmptyScope).unwrap(), 2.0);
130        assert_eq!(b.eval(&EmptyScope).unwrap(), 2.0);
131    }
132
133    #[test]
134    fn plain_arithmetic() {
135        let expr = Expr::parse("= (2 + 3) * 4 - 10 / 2 % 3").unwrap();
136        assert_eq!(expr.eval(&EmptyScope).unwrap(), 18.0);
137    }
138
139    #[test]
140    fn ternary_short_circuits() {
141        let expr = Expr::parse("= 1 > 0 ? 5 : (1 / 0)").unwrap();
142        assert_eq!(expr.eval(&EmptyScope).unwrap(), 5.0);
143    }
144
145    #[test]
146    fn ternary_untaken_branch_errors_are_not_raised() {
147        let expr = Expr::parse("= 1 < 0 ? $missing : 42").unwrap();
148        assert_eq!(expr.eval(&EmptyScope).unwrap(), 42.0);
149    }
150
151    #[test]
152    fn constants_resolve() {
153        let expr = Expr::parse("= PI + TAU + E").unwrap();
154        assert!((expr.eval(&EmptyScope).unwrap() - (PI + TAU + std::f64::consts::E)).abs() < 1e-12);
155    }
156
157    #[test]
158    fn free_vars_and_is_static() {
159        let dynamic = Expr::parse("= $t * 2").unwrap();
160        assert_eq!(dynamic.free_vars(), vec!["t".to_string()]);
161        assert!(!dynamic.is_static());
162
163        let static_expr = Expr::parse("= $W / 2 + $i").unwrap();
164        assert_eq!(
165            static_expr.free_vars(),
166            vec!["W".to_string(), "i".to_string()]
167        );
168        assert!(static_expr.is_static());
169
170        let duration_expr = Expr::parse("= $duration").unwrap();
171        assert!(!duration_expr.is_static());
172    }
173
174    #[test]
175    fn node_ref_is_never_static() {
176        let expr = Expr::parse("= node(\"badge_0\", \"x\") + 1").unwrap();
177        assert!(!expr.is_static());
178        assert!(expr.free_vars().is_empty());
179    }
180
181    #[test]
182    fn node_ref_resolves_through_scope() {
183        struct NodeScope;
184        impl Scope for NodeScope {
185            fn var(&self, _name: &str) -> Option<f64> {
186                None
187            }
188            fn node_prop(&self, id: &str, prop: &str) -> Option<f64> {
189                if id == "badge_0" && prop == "x" {
190                    Some(100.0)
191                } else {
192                    None
193                }
194            }
195        }
196        let expr = Expr::parse("= node(\"badge_0\", \"x\") + 1").unwrap();
197        assert_eq!(expr.eval(&NodeScope).unwrap(), 101.0);
198    }
199
200    #[test]
201    fn unknown_var_errors_at_eval() {
202        let expr = Expr::parse("= $nope").unwrap();
203        let err = expr.eval(&EmptyScope).unwrap_err();
204        assert_eq!(err, ExprError::UnknownIdent("nope".to_string()));
205    }
206
207    #[test]
208    fn eight_badges_on_a_circle_match_real_cosines() {
209        let count = 8;
210        for i in 0..count {
211            let scope = MapScope(&[("i", i as f64), ("count", count as f64), ("W", 1080.0)]);
212            let expr = Expr::parse("= $W/2 + cos($i / $count * TAU - PI/2) * 700").unwrap();
213            assert!(expr.is_static());
214            let got = expr.eval(&scope).unwrap();
215            let want = 1080.0 / 2.0 + (i as f64 / count as f64 * TAU - PI / 2.0).cos() * 700.0;
216            assert!(
217                (got - want).abs() < 1e-9,
218                "badge {i}: got {got}, want {want}"
219            );
220        }
221    }
222
223    #[test]
224    fn rand_is_deterministic_across_separate_evaluations() {
225        let expr = Expr::parse("= rand(42)").unwrap();
226        let a = expr.eval(&EmptyScope).unwrap();
227        let b = expr.eval(&EmptyScope).unwrap();
228        assert_eq!(a, b);
229        let c = Expr::parse("= rand(42)")
230            .unwrap()
231            .eval(&EmptyScope)
232            .unwrap();
233        assert_eq!(a, c);
234    }
235
236    #[test]
237    fn deeply_nested_expression_is_rejected_at_parse() {
238        let src = format!("={}1{}", "(".repeat(500), ")".repeat(500));
239        let err = Expr::parse(&src).unwrap_err();
240        assert!(matches!(err, ExprError::TooDeep(_)), "got {err:?}");
241    }
242
243    #[test]
244    fn huge_exponent_chain_is_rejected_at_parse() {
245        let mut body = "2".to_string();
246        for _ in 0..500 {
247            body = format!("pow({body}, 2)");
248        }
249        let src = format!("= {body}");
250        let err = Expr::parse(&src).unwrap_err();
251        assert!(matches!(err, ExprError::TooDeep(_)), "got {err:?}");
252    }
253
254    #[test]
255    fn computed_untagged_literal_vs_expr_for_numeric_t() {
256        let lit: Computed<f64> = serde_json::from_str("42.5").unwrap();
257        assert_eq!(lit, Computed::Literal(42.5));
258
259        let expr: Computed<f64> = serde_json::from_str("\"= $W/2\"").unwrap();
260        assert_eq!(expr, Computed::Expr("= $W/2".to_string()));
261    }
262
263    #[test]
264    fn computed_round_trips_through_serde() {
265        let lit: Computed<f64> = Computed::Literal(10.0);
266        let json = serde_json::to_string(&lit).unwrap();
267        assert_eq!(json, "10.0");
268
269        let expr: Computed<f64> = Computed::Expr("= 1 + 1".to_string());
270        let json = serde_json::to_string(&expr).unwrap();
271        assert_eq!(json, "\"= 1 + 1\"");
272    }
273
274    #[test]
275    fn a_static_expression_over_for_each_bindings_folds_at_load() {
276        struct Fixed;
277        impl Scope for Fixed {
278            fn var(&self, name: &str) -> Option<f64> {
279                match name {
280                    "W" => Some(1080.0),
281                    "i" => Some(3.0),
282                    "count" => Some(8.0),
283                    _ => None,
284                }
285            }
286        }
287
288        let expr = Expr::parse("= $W/2 + cos($i / $count * TAU) * 700").unwrap();
289        assert!(expr.is_static());
290        let x = expr.eval(&Fixed).unwrap();
291        let want = 1080.0 / 2.0 + (3.0_f64 / 8.0 * std::f64::consts::TAU).cos() * 700.0;
292        assert!((x - want).abs() < 1e-9);
293    }
294}