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#[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}