Skip to main content

eui_core/
expr.rs

1//! The macro template's value language (`catalog/macros.json` `$comment`):
2//! `{props.x}` paths with `|fallback`, `len(path)`, `range(n)`, `!path`,
3//! string interpolation, and the `$map` / `$cond` / `$eq` / `$lt` / `$add` /
4//! `$sub` / `$clamp` / `$percent` / `$coalesce` / `$text` / `$not` / `$fill`
5//! value objects. Mirrors `src/expr.ts` line by line.
6//!
7//! Round 1 (docs/round-1-contract.md §1): an input may be an A2UI DYNAMIC
8//! value — a data binding `{path}` or a function call `{call, args}` — which
9//! has no value at expansion time. The evaluator then EMITS a call over the
10//! core functions that the renderer evaluates at bind time (`data::bind_tree`)
11//! instead of computing. A whole-string `{props.x}` that is a MEMBER passes a
12//! dynamic value through unchanged (two-way binding survives); as an OPERAND
13//! of a `$`-object a `|fallback` is kept as `fallback{value, default}`.
14//! `$fill` ALWAYS emits `fill` (its template is a `$string.<id>` the
15//! surface's string table resolves at bind time).
16//!
17//! JavaScript's `undefined` is `None`; JSON `null` is `Some(Value::Null)`.
18
19use indexmap::IndexMap;
20use serde_json::{Map, Value};
21
22use crate::json;
23use crate::types::Props;
24
25/// What a template expression can see: the node id, its props, and the
26/// `$each` loop variables (`item`, `index`, …).
27#[derive(Debug, Clone)]
28pub struct ExprContext<'a> {
29    pub id: &'a str,
30    pub props: &'a Props,
31    pub vars: IndexMap<String, Value>,
32}
33
34/// An A2UI data binding: an object with exactly one key, `path` (a string).
35pub fn is_binding(value: &Value) -> bool {
36    value.as_object().is_some_and(|o| o.len() == 1 && o.get("path").is_some_and(Value::is_string))
37}
38
39/// An A2UI function call: an object with a string `call`.
40pub fn is_call(value: &Value) -> bool {
41    value.as_object().is_some_and(|o| o.get("call").is_some_and(Value::is_string))
42}
43
44/// A value the expander cannot evaluate: the renderer will.
45pub fn is_dynamic(value: &Value) -> bool {
46    is_binding(value) || is_call(value)
47}
48
49fn is_dynamic_opt(value: &Option<Value>) -> bool {
50    value.as_ref().is_some_and(is_dynamic)
51}
52
53/// `undefined`, `null`, `false` and `""` are false; 0 is TRUE (a count of
54/// zero still shows).
55pub fn truthy(value: &Value) -> bool {
56    !matches!(value, Value::Null | Value::Bool(false)) && value.as_str() != Some("")
57}
58
59/// [`truthy`] over an optional (`None` = undefined = false).
60pub fn truthy_opt(value: Option<&Value>) -> bool {
61    value.is_some_and(truthy)
62}
63
64/// `{call: name, args}` with undefined args dropped (the TS `call`).
65pub fn call(name: &str, args: Vec<(&str, Option<Value>)>) -> Value {
66    let mut out = Map::new();
67    for (k, v) in args {
68        if let Some(v) = v {
69            out.insert(k.to_string(), v);
70        }
71    }
72    let mut c = Map::new();
73    c.insert("call".into(), Value::String(name.to_string()));
74    c.insert("args".into(), Value::Object(out));
75    Value::Object(c)
76}
77
78fn is_number_literal(text: &str) -> bool {
79    let body = text.strip_prefix('-').unwrap_or(text);
80    let (int, frac) = match body.split_once('.') {
81        Some((i, f)) => (i, Some(f)),
82        None => (body, None),
83    };
84    let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
85    digits(int) && frac.is_none_or(digits)
86}
87
88fn parse_literal(text: &str) -> Value {
89    match text {
90        "true" => Value::Bool(true),
91        "false" => Value::Bool(false),
92        "null" => Value::Null,
93        _ if is_number_literal(text) => json::number(text.parse::<f64>().unwrap_or(0.0)),
94        _ => Value::String(text.to_string()),
95    }
96}
97
98/// JS `cur[key]` over a JSON value.
99fn index(cur: &Value, key: &str) -> Option<Value> {
100    match cur {
101        Value::Object(map) => map.get(key).cloned(),
102        Value::Array(items) => {
103            if key == "length" {
104                return Some(json::number(items.len() as f64));
105            }
106            key.parse::<usize>().ok().filter(|_| !key.starts_with('+')).and_then(|i| items.get(i).cloned())
107        }
108        Value::String(s) => {
109            if key == "length" {
110                return Some(json::number(s.encode_utf16().count() as f64));
111            }
112            let i = key.parse::<usize>().ok()?;
113            let unit = *s.encode_utf16().collect::<Vec<_>>().get(i)?;
114            Some(Value::String(String::from_utf16_lossy(&[unit])))
115        }
116        _ => None,
117    }
118}
119
120fn lookup(path: &str, ctx: &ExprContext) -> Option<Value> {
121    if path == "id" {
122        return Some(Value::String(ctx.id.to_string()));
123    }
124    let mut parts = path.split('.');
125    let head = parts.next().unwrap_or("");
126    let mut cur = if head == "props" {
127        Value::Object(ctx.props.clone())
128    } else {
129        ctx.vars.get(head)?.clone()
130    };
131    for key in parts {
132        if cur.is_null() {
133            return None;
134        }
135        cur = index(&cur, key)?;
136    }
137    Some(cur)
138}
139
140/// The TS `toNumber`: numbers stay, everything else `Number(v ?? 0) || 0`.
141pub fn to_number(value: Option<&Value>) -> f64 {
142    match value {
143        Some(Value::Number(n)) => n.as_f64().unwrap_or(0.0),
144        Some(v) => {
145            let n = json::to_number(v);
146            if n.is_nan() {
147                0.0
148            } else {
149                n
150            }
151        }
152        None => 0.0,
153    }
154}
155
156/// A string that spells a finite number (`"5"`, `" 2.5 "`), else `None`.
157fn numeric_string(value: &str) -> Option<f64> {
158    let text = value.trim();
159    if text.is_empty() {
160        return None;
161    }
162    text.parse::<f64>().ok().filter(|n| n.is_finite())
163}
164
165/// `eq` (and `$eq`): strict equality, except that a NUMBER and a string
166/// spelling that number are equal (`5` vs `"5"`: form and URL state arrive as
167/// strings). Nothing else coerces (`0` vs `""`, `1` vs `true` differ).
168pub fn values_equal(a: Option<&Value>, b: Option<&Value>) -> bool {
169    match (a, b) {
170        (None, None) => true,
171        (Some(Value::Number(n)), Some(Value::String(s))) | (Some(Value::String(s)), Some(Value::Number(n))) => numeric_string(s) == n.as_f64(),
172        (Some(a), Some(b)) => json::strict_eq(a, b),
173        _ => false,
174    }
175}
176
177/// `clamp`: the value as a number held inside [min, max]; a missing bound
178/// (undefined or null) does not clamp; `min` wins when max < min.
179pub fn clamp_number(value: Option<&Value>, min: Option<&Value>, max: Option<&Value>) -> f64 {
180    let mut n = to_number(value);
181    if let Some(max) = max.filter(|m| !m.is_null()) {
182        n = n.min(to_number(Some(max)));
183    }
184    if let Some(min) = min.filter(|m| !m.is_null()) {
185        n = n.max(to_number(Some(min)));
186    }
187    n
188}
189
190/// `fill`: the template (already resolved through the string table) with its
191/// `{name}` placeholders filled from params (`strings::format_string`).
192pub fn fill_template(template: Option<&Value>, params: Option<&Value>) -> String {
193    let text = match template {
194        None | Some(Value::Null) => String::new(),
195        Some(v) => json::to_js_string(v),
196    };
197    let empty = Map::new();
198    let params = params.and_then(Value::as_object).unwrap_or(&empty);
199    crate::strings::format_string(&text, params)
200}
201
202/// UTF-16 length (JS `.length`).
203fn js_len(value: Option<&Value>) -> usize {
204    match value {
205        Some(Value::Array(items)) => items.len(),
206        Some(Value::String(s)) => s.encode_utf16().count(),
207        _ => 0,
208    }
209}
210
211/// `^(len|range)\((.+)\)$`
212fn function_form(text: &str) -> Option<(&str, &str)> {
213    for name in ["len", "range"] {
214        if let Some(rest) = text.strip_prefix(name).and_then(|r| r.strip_prefix('(')).and_then(|r| r.strip_suffix(')')) {
215            if !rest.is_empty() && !rest.contains('\n') {
216                return Some((name, rest));
217            }
218        }
219    }
220    None
221}
222
223/// The most indexes `range(n)` yields (R8 F51): a Rating's `max` of 1e8 or
224/// 1e300 would otherwise allocate that many items before the component
225/// budget could refuse them. Equals `limits::MAX_TEMPLATE_ITEMS`; a larger
226/// `n` is silently clamped (the reducer's `maxComponents` reports the
227/// overflow). Mirrors `MAX_RANGE_ITEMS` in `src/expr.ts`.
228pub const MAX_RANGE_ITEMS: usize = 10_000;
229
230/// One expression: `len(path)`, `range(n)`, `path|fallback`, `!path`, `path`.
231/// The function forms wrap the whole expression and are tried first, so a
232/// fallback inside their parentheses stays theirs.
233pub fn eval_expr(expr: &str, ctx: &ExprContext) -> Option<Value> {
234    let text = expr.trim();
235    if let Some((name, inner)) = function_form(text) {
236        let inner = eval_expr(inner, ctx);
237        if name == "len" {
238            if is_dynamic_opt(&inner) {
239                return Some(call("len", vec![("value", inner)]));
240            }
241            return Some(json::number(js_len(inner.as_ref()) as f64));
242        }
243        // range: the indexes 0..n-1 (at most MAX_RANGE_ITEMS; NaN = 0); a
244        // bound count cannot be expanded (→ no items).
245        if is_dynamic_opt(&inner) {
246            return None;
247        }
248        let n = to_number(inner.as_ref()).floor().clamp(0.0, MAX_RANGE_ITEMS as f64) as usize;
249        return Some(Value::Array((0..n).map(|i| json::number(i as f64)).collect()));
250    }
251    if let Some(bar) = text.find('|') {
252        let value = eval_expr(&text[..bar], ctx);
253        let fallback = parse_literal(&text[bar + 1..]);
254        return match value {
255            None | Some(Value::Null) => Some(fallback),
256            Some(v) if is_dynamic(&v) => Some(call("fallback", vec![("value", Some(v)), ("default", Some(fallback))])),
257            some => some,
258        };
259    }
260    if let Some(rest) = text.strip_prefix('!') {
261        let value = eval_expr(rest, ctx);
262        if is_dynamic_opt(&value) {
263            return Some(call("not", vec![("value", value)]));
264        }
265        return Some(Value::Bool(!truthy_opt(value.as_ref())));
266    }
267    lookup(text, ctx)
268}
269
270/// The member rule: a dynamic value passes through untouched; the
271/// `fallback` a `|fallback` wrapped it in is unwrapped again.
272fn pass_through(value: Option<Value>) -> Option<Value> {
273    if let Some(v) = &value {
274        if is_call(v) && v.get("call").and_then(Value::as_str) == Some("fallback") {
275            if let Some(inner) = v.get("args").and_then(|a| a.get("value")).filter(|x| is_dynamic(x)) {
276                return Some(inner.clone());
277            }
278        }
279    }
280    value
281}
282
283/// An operand of a `$`-object (or its result): a whole string evaluates as
284/// its expression, keeping a `|fallback` as `fallback{…}` when dynamic.
285fn eval_operand(value: Option<&Value>, ctx: &ExprContext) -> Option<Value> {
286    let value = value?;
287    if let Value::String(s) = value {
288        if let Some(expr) = whole(s) {
289            return eval_expr(expr, ctx);
290        }
291    }
292    eval_value(value, ctx)
293}
294
295/// A condition decided now (`Bool`) or the dynamic value it depends on.
296#[derive(Debug, Clone, PartialEq)]
297pub enum Decided {
298    Bool(bool),
299    Dynamic(Value),
300}
301
302/// A condition: a string expression or a value object (`$eq` …). `Bool`
303/// when it can be decided now; the dynamic value when it cannot.
304pub fn eval_condition_value(cond: Option<&Value>, ctx: &ExprContext) -> Decided {
305    let value = match cond {
306        Some(Value::String(s)) => eval_expr(s, ctx),
307        other => eval_operand(other, ctx),
308    };
309    match value {
310        Some(v) if is_dynamic(&v) => Decided::Dynamic(v),
311        other => Decided::Bool(truthy_opt(other.as_ref())),
312    }
313}
314
315/// A condition decided NOW: a dynamic condition counts as true (the part
316/// exists; `visible` carries the bound test, see `macros`).
317pub fn eval_condition(cond: &Value, ctx: &ExprContext) -> bool {
318    !matches!(eval_condition_value(Some(cond), ctx), Decided::Bool(false))
319}
320
321/// `{expr}` occurrences replaced (the TS `/\{([^{}]+)\}/g`); a dynamic piece
322/// turns the whole string into `concat{values}`.
323fn interpolate(value: &str, ctx: &ExprContext) -> Value {
324    let mut parts: Vec<Option<Value>> = Vec::new();
325    let mut literal = String::new();
326    let mut dynamic = false;
327    let mut rest = value;
328    while let Some(open) = rest.find('{') {
329        literal.push_str(&rest[..open]);
330        let after = &rest[open + 1..];
331        match after.find(['{', '}']) {
332            Some(close) if close > 0 && after.as_bytes()[close] == b'}' => {
333                if !literal.is_empty() {
334                    parts.push(Some(Value::String(std::mem::take(&mut literal))));
335                }
336                let v = eval_expr(&after[..close], ctx);
337                dynamic |= is_dynamic_opt(&v);
338                parts.push(v);
339                rest = &after[close + 1..];
340            }
341            _ => {
342                literal.push('{');
343                rest = after;
344            }
345        }
346    }
347    literal.push_str(rest);
348    if !literal.is_empty() {
349        parts.push(Some(Value::String(literal)));
350    }
351    if dynamic {
352        let values: Vec<Value> = parts.into_iter().flatten().filter(|p| !p.is_null() && p.as_str() != Some("")).collect();
353        return call("concat", vec![("values", Some(Value::Array(values)))]);
354    }
355    let mut out = String::new();
356    for p in parts.iter().flatten() {
357        if !p.is_null() {
358            out.push_str(&json::to_js_string(p));
359        }
360    }
361    Value::String(out)
362}
363
364/// The whole-string form `{expr}` (the TS `/^\{([^{}]+)\}$/`).
365fn whole(value: &str) -> Option<&str> {
366    let inner = value.strip_prefix('{')?.strip_suffix('}')?;
367    (!inner.is_empty() && !inner.contains(['{', '}'])).then_some(inner)
368}
369
370fn arg(args: &Value, i: usize) -> Option<&Value> {
371    args.as_array().and_then(|a| a.get(i))
372}
373
374/// The operands of `$eq`/`$lt`/`$add`/`$sub`/`$percent` (the TS maps over
375/// the array it finds, so a short array leaves the rest undefined).
376fn pair(args: &Value, ctx: &ExprContext) -> (Option<Value>, Option<Value>) {
377    (eval_operand(arg(args, 0), ctx), eval_operand(arg(args, 1), ctx))
378}
379
380
381/// Any template value: strings interpolate, arrays and objects recurse, the
382/// `$`-objects compute (or emit a call when an input is dynamic). `None`
383/// (undefined) results are dropped from objects and arrays; `null` is kept.
384pub fn eval_value(value: &Value, ctx: &ExprContext) -> Option<Value> {
385    match value {
386        Value::String(s) => {
387            if let Some(expr) = whole(s) {
388                return pass_through(eval_expr(expr, ctx));
389            }
390            Some(interpolate(s, ctx))
391        }
392        Value::Array(items) => Some(Value::Array(items.iter().filter_map(|item| eval_value(item, ctx)).collect())),
393        Value::Object(obj) => eval_object(obj, ctx),
394        other => Some(other.clone()),
395    }
396}
397
398fn eval_object(obj: &Map<String, Value>, ctx: &ExprContext) -> Option<Value> {
399    if let Some(spec) = obj.get("$map") {
400        let from = spec.get("from").and_then(Value::as_str).and_then(|f| eval_expr(f, ctx));
401        let empty = Map::new();
402        let cases = spec.get("cases").and_then(Value::as_object).unwrap_or(&empty);
403        if is_dynamic_opt(&from) {
404            let mut out = Map::new();
405            for (k, v) in cases {
406                if let Some(evaluated) = eval_operand(Some(v), ctx) {
407                    out.insert(k.clone(), evaluated);
408                }
409            }
410            let default = eval_operand(spec.get("default"), ctx);
411            return Some(call("map", vec![("value", from), ("cases", Some(Value::Object(out))), ("default", default)]));
412        }
413        let key = match &from {
414            None | Some(Value::Null) => String::new(),
415            Some(v) => json::to_js_string(v),
416        };
417        let hit = cases.get(&key).or_else(|| spec.get("default"));
418        return eval_operand(hit, ctx);
419    }
420    if let Some(args) = obj.get("$cond") {
421        let decided = eval_condition_value(arg(args, 0), ctx);
422        return match decided {
423            Decided::Dynamic(test) => {
424                Some(call("cond", vec![("if", Some(test)), ("then", eval_operand(arg(args, 1), ctx)), ("else", eval_operand(arg(args, 2), ctx))]))
425            }
426            Decided::Bool(true) => eval_operand(arg(args, 1), ctx),
427            Decided::Bool(false) => eval_operand(arg(args, 2), ctx),
428        };
429    }
430    if let Some(args) = obj.get("$eq") {
431        let (a, b) = pair(args, ctx);
432        if is_dynamic_opt(&a) || is_dynamic_opt(&b) {
433            return Some(call("eq", vec![("a", a), ("b", b)]));
434        }
435        return Some(Value::Bool(values_equal(a.as_ref(), b.as_ref())));
436    }
437    if let Some(args) = obj.get("$lt") {
438        let (a, b) = pair(args, ctx);
439        if is_dynamic_opt(&a) || is_dynamic_opt(&b) {
440            return Some(call("lt", vec![("a", a), ("b", b)]));
441        }
442        return Some(Value::Bool(to_number(a.as_ref()) < to_number(b.as_ref())));
443    }
444    if let Some(args) = obj.get("$add") {
445        let (a, b) = pair(args, ctx);
446        if is_dynamic_opt(&a) || is_dynamic_opt(&b) {
447            return Some(call("add", vec![("a", a), ("b", b)]));
448        }
449        return Some(json::number(to_number(a.as_ref()) + to_number(b.as_ref())));
450    }
451    if let Some(args) = obj.get("$sub") {
452        let (a, b) = pair(args, ctx);
453        if is_dynamic_opt(&a) || is_dynamic_opt(&b) {
454            return Some(call("sub", vec![("a", a), ("b", b)]));
455        }
456        return Some(json::number(to_number(a.as_ref()) - to_number(b.as_ref())));
457    }
458    if let Some(args) = obj.get("$clamp") {
459        let (v, min, max) = (eval_operand(arg(args, 0), ctx), eval_operand(arg(args, 1), ctx), eval_operand(arg(args, 2), ctx));
460        if is_dynamic_opt(&v) || is_dynamic_opt(&min) || is_dynamic_opt(&max) {
461            return Some(call("clamp", vec![("value", v), ("min", min), ("max", max)]));
462        }
463        return Some(json::number(clamp_number(v.as_ref(), min.as_ref(), max.as_ref())));
464    }
465    if let Some(args) = obj.get("$fill") {
466        // Always emitted: the template is (usually) a `$string.<id>` only the
467        // surface's string table can resolve, at bind time.
468        let mut filled = Map::new();
469        if let Some(params) = arg(args, 1).and_then(Value::as_object) {
470            for (k, v) in params {
471                if let Some(evaluated) = eval_operand(Some(v), ctx) {
472                    filled.insert(k.clone(), evaluated);
473                }
474            }
475        }
476        return Some(call("fill", vec![("template", eval_operand(arg(args, 0), ctx)), ("params", Some(Value::Object(filled)))]));
477    }
478    if let Some(args) = obj.get("$percent") {
479        let (v, max) = pair(args, ctx);
480        if is_dynamic_opt(&v) || is_dynamic_opt(&max) {
481            return Some(call("percent", vec![("value", v), ("max", max)]));
482        }
483        return Some(Value::String(percent(v.as_ref(), max.as_ref())));
484    }
485    if let Some(spec) = obj.get("$text") {
486        let v = match spec {
487            Value::String(s) => eval_expr(s, ctx),
488            other => eval_operand(Some(other), ctx),
489        };
490        if is_dynamic_opt(&v) {
491            return Some(call("text", vec![("value", v)]));
492        }
493        return match v {
494            None | Some(Value::Null) => None,
495            Some(v) => Some(Value::String(json::to_js_string(&v))),
496        };
497    }
498    if let Some(candidates) = obj.get("$coalesce") {
499        let mut out: Vec<Value> = Vec::new();
500        for candidate in candidates.as_array().into_iter().flatten() {
501            let Some(v) = eval_operand(Some(candidate), ctx) else { continue };
502            if is_dynamic(&v) {
503                out.push(v);
504                continue;
505            }
506            if !truthy(&v) {
507                continue;
508            }
509            if out.is_empty() {
510                return Some(v);
511            }
512            out.push(v);
513            break;
514        }
515        return match out.len() {
516            0 => None,
517            1 => out.pop(),
518            _ => Some(call("coalesce", vec![("values", Some(Value::Array(out)))])),
519        };
520    }
521    if obj.contains_key("$not") {
522        return match eval_condition_value(obj.get("$not"), ctx) {
523            Decided::Dynamic(v) => Some(call("not", vec![("value", Some(v))])),
524            Decided::Bool(b) => Some(Value::Bool(!b)),
525        };
526    }
527    let mut out = Map::new();
528    for (k, v) in obj {
529        let Some(evaluated) = eval_value(v, ctx) else { continue };
530        let key = if k.contains('{') {
531            match eval_value(&Value::String(k.clone()), ctx) {
532                Some(Value::String(s)) => s,
533                _ => k.clone(),
534            }
535        } else {
536            k.clone()
537        };
538        out.insert(key, evaluated);
539    }
540    Some(Value::Object(out))
541}
542
543/// `$percent`'s arithmetic, shared with the core `percent` function: the
544/// share of `max` as "N%", clamped to 0..100, two decimals.
545pub fn percent(value: Option<&Value>, max: Option<&Value>) -> String {
546    let m = to_number(max);
547    let pct = if m > 0.0 { to_number(value) / m * 100.0 } else { 0.0 };
548    let clamped = pct.clamp(0.0, 100.0);
549    let rounded = js_round(clamped * 100.0) / 100.0;
550    format!("{}%", json::number_to_string(rounded))
551}
552
553/// JS `Math.round` (half rounds UP, also for negatives).
554pub fn js_round(v: f64) -> f64 {
555    (v + 0.5).floor()
556}
557
558/// The core value functions (`core.catalog.json` `functions.core`; `set` is
559/// an action, `data::run_action`) once the arguments are resolved: the
560/// reference every client function table mirrors (`src/expr.ts
561/// CORE_FUNCTIONS`). Outer `None` = not a core function; inner `None` =
562/// undefined.
563pub fn core_function(name: &str, args: &Map<String, Value>) -> Option<Option<Value>> {
564    let a = |k: &str| args.get(k);
565    Some(match name {
566        "percent" => Some(Value::String(percent(a("value"), a("max")))),
567        "add" => Some(json::number(to_number(a("a")) + to_number(a("b")))),
568        "sub" => Some(json::number(to_number(a("a")) - to_number(a("b")))),
569        "eq" => Some(Value::Bool(values_equal(a("a"), a("b")))),
570        "lt" => Some(Value::Bool(to_number(a("a")) < to_number(a("b")))),
571        "clamp" => Some(json::number(clamp_number(a("value"), a("min"), a("max")))),
572        "cond" => {
573            if truthy_opt(a("if")) {
574                a("then").cloned()
575            } else {
576                a("else").cloned()
577            }
578        }
579        "fallback" => match a("value") {
580            None | Some(Value::Null) => a("default").cloned(),
581            Some(v) => Some(v.clone()),
582        },
583        "concat" => Some(Value::String(match a("values") {
584            Some(Value::Array(values)) => values.iter().map(|v| if v.is_null() { String::new() } else { json::to_js_string(v) }).collect(),
585            _ => String::new(),
586        })),
587        "coalesce" => match a("values") {
588            Some(Value::Array(values)) => values.iter().find(|v| truthy(v)).cloned(),
589            _ => None,
590        },
591        "text" => match a("value") {
592            None | Some(Value::Null) => None,
593            Some(v) => Some(Value::String(json::to_js_string(v))),
594        },
595        "map" => {
596            let key = match a("value") {
597                None | Some(Value::Null) => String::new(),
598                Some(v) => json::to_js_string(v),
599            };
600            match a("cases").and_then(Value::as_object).and_then(|t| t.get(&key)) {
601                Some(hit) => Some(hit.clone()),
602                None => a("default").cloned(),
603            }
604        }
605        "len" => Some(json::number(js_len(a("value")) as f64)),
606        "fill" => Some(Value::String(fill_template(a("template"), a("params")))),
607        "filter" => Some(Value::Array(filter_items(a("items"), a("query"), a("fields"), a("where")))),
608        _ => return None,
609    })
610}
611
612/// The core function names in catalog order (`set` = the action; the
613/// format functions live in `format`).
614pub const CORE_FUNCTION_NAMES: &[&str] = &["percent", "add", "sub", "eq", "lt", "clamp", "cond", "fallback", "concat", "coalesce", "text", "map", "len", "fill", "set", "filter"];
615
616/// Round 4 (VAPP-103) `filter` (`src/expr.ts filterItems`): the items of an
617/// array that match, in order. `where` = field → value, every entry must
618/// hold (`eq` equality); an entry whose value is null or "" does NOT
619/// constrain. `query` = a case-insensitive substring of ANY of `fields`
620/// (the item's own string and number values when `fields` is absent; a
621/// string or number item matches itself); a missing or blank query does not
622/// constrain. Not an array → [].
623pub fn filter_items(items: Option<&Value>, query: Option<&Value>, fields: Option<&Value>, r#where: Option<&Value>) -> Vec<Value> {
624    let Some(Value::Array(items)) = items else { return vec![] };
625    let needle = match query {
626        Some(Value::String(s)) => s.trim().to_lowercase(),
627        Some(n @ Value::Number(_)) => json::to_js_string(n),
628        _ => String::new(),
629    };
630    let keys: Option<Vec<&str>> = fields.and_then(Value::as_array).map(|f| f.iter().filter_map(Value::as_str).collect());
631    let conditions: Vec<(&String, &Value)> = match r#where {
632        Some(Value::Object(m)) => m.iter().filter(|(_, v)| !v.is_null() && v.as_str() != Some("")).collect(),
633        _ => vec![],
634    };
635    let searchable = |v: &Value| match v {
636        Value::String(s) => Some(s.to_lowercase()),
637        Value::Number(_) => Some(json::to_js_string(v)),
638        _ => None,
639    };
640    items
641        .iter()
642        .filter(|item| {
643            let record = item.as_object();
644            if !conditions.iter().all(|(field, want)| values_equal(record.and_then(|r| r.get(field.as_str())), Some(want))) {
645                return false;
646            }
647            if needle.is_empty() {
648                return true;
649            }
650            match record {
651                Some(r) => match &keys {
652                    Some(keys) => keys.iter().filter_map(|k| r.get(*k)).any(|v| searchable(v).is_some_and(|s| s.contains(&needle))),
653                    None => r.values().any(|v| searchable(v).is_some_and(|s| s.contains(&needle))),
654                },
655                None => searchable(item).is_some_and(|s| s.contains(&needle)),
656            }
657        })
658        .cloned()
659        .collect()
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use serde_json::json;
666
667    fn with_ctx(f: impl FnOnce(&ExprContext)) {
668        let props = json!({"gap": "lg", "count": 0, "title": "", "items": ["a", "b"], "selected": "b", "page": 2});
669        let props = props.as_object().unwrap().clone();
670        let mut vars = IndexMap::new();
671        vars.insert("item".to_string(), json!({"value": "b", "label": "Bee"}));
672        vars.insert("index".to_string(), json!(1));
673        let ctx = ExprContext { id: "n1", props: &props, vars };
674        f(&ctx);
675    }
676
677    fn with_bound(f: impl FnOnce(&ExprContext)) {
678        let props = json!({"page": {"path": "/page"}, "total": 5, "open": {"path": "/open"}, "value": {"call": "len", "args": {"value": {"path": "/xs"}}}});
679        let props = props.as_object().unwrap().clone();
680        let ctx = ExprContext { id: "p", props: &props, vars: IndexMap::new() };
681        f(&ctx);
682    }
683
684    fn ev(v: Value, ctx: &ExprContext) -> Option<Value> {
685        eval_value(&v, ctx)
686    }
687
688    #[test]
689    fn template_values_paths_fallbacks_length_negation_and_the_id() {
690        with_ctx(|ctx| {
691            assert_eq!(ev(json!("{props.gap}"), ctx), Some(json!("lg")));
692            assert_eq!(ev(json!("{props.missing|md}"), ctx), Some(json!("md")));
693            assert_eq!(ev(json!("{props.missing|3}"), ctx), Some(json!(3)));
694            assert_eq!(ev(json!("{props.count}"), ctx), Some(json!(0)));
695            assert_eq!(ev(json!("{len(props.items)}"), ctx), Some(json!(2)));
696            assert_eq!(ev(json!("{range(props.page)}"), ctx), Some(json!([0, 1])));
697            assert_eq!(ev(json!("{!props.title}"), ctx), Some(json!(true)));
698            assert_eq!(ev(json!("{id}"), ctx), Some(json!("n1")));
699            assert_eq!(ev(json!("{item.label} #{index}"), ctx), Some(json!("Bee #1")));
700            assert_eq!(ev(json!("$spacing.{props.gap|md}"), ctx), Some(json!("$spacing.lg")));
701            assert_eq!(ev(json!("{props.missing}"), ctx), None);
702        });
703    }
704
705    #[test]
706    fn template_values_conditions_0_is_true_empty_string_and_missing_are_false() {
707        with_ctx(|ctx| {
708            assert!(eval_condition(&json!("props.count"), ctx));
709            assert!(!eval_condition(&json!("props.title"), ctx));
710            assert!(!eval_condition(&json!("props.missing"), ctx));
711            assert!(eval_condition(&json!("!props.missing"), ctx));
712            assert!(eval_condition(&json!({"$eq": ["{item.value}", "{props.selected}"]}), ctx));
713        });
714    }
715
716    #[test]
717    fn template_values_the_value_objects() {
718        with_ctx(|ctx| {
719            assert_eq!(ev(json!({"$map": {"from": "props.gap", "cases": {"lg": 16}, "default": 8}}), ctx), Some(json!(16)));
720            assert_eq!(ev(json!({"$map": {"from": "props.missing", "cases": {"lg": 16}, "default": "{props.gap}"}}), ctx), Some(json!("lg")));
721            assert_eq!(ev(json!({"$cond": ["props.title", "yes", "no"]}), ctx), Some(json!("no")));
722            assert_eq!(ev(json!({"$add": ["{props.page}", -1]}), ctx), Some(json!(1)));
723            assert_eq!(ev(json!({"$sub": ["{props.page}", 1]}), ctx), Some(json!(1)));
724            assert_eq!(ev(json!({"$lt": ["{props.count}", 1]}), ctx), Some(json!(true)));
725            assert_eq!(ev(json!({"$percent": ["{props.page}", 8]}), ctx), Some(json!("25%")));
726            assert_eq!(ev(json!({"$percent": [150, 100]}), ctx), Some(json!("100%")));
727            assert_eq!(ev(json!({"$percent": [1, 3]}), ctx), Some(json!("33.33%")));
728            assert_eq!(ev(json!({"$coalesce": ["{props.title}", "{props.missing}", "Choose"]}), ctx), Some(json!("Choose")));
729            assert_eq!(ev(json!({"$text": "props.count"}), ctx), Some(json!("0")));
730            assert_eq!(ev(json!({"$text": "props.missing"}), ctx), None);
731            assert_eq!(ev(json!({"$not": "props.title"}), ctx), Some(json!(true)));
732            assert_eq!(ev(json!({"$clamp": ["{props.page}", 3, 5]}), ctx), Some(json!(3)));
733            assert_eq!(ev(json!({"$eq": ["{props.page}", "2"]}), ctx), Some(json!(true)));
734            assert_eq!(
735                ev(json!({"$fill": ["$string.pageOf", {"page": "{props.page}", "x": "{props.missing}"}]}), ctx),
736                Some(json!({"call": "fill", "args": {"template": "$string.pageOf", "params": {"page": 2}}}))
737            );
738        });
739    }
740
741    #[test]
742    fn template_values_objects_drop_undefined_members_and_arrays_drop_undefined_items() {
743        with_ctx(|ctx| {
744            assert_eq!(ev(json!({"a": "{props.missing}", "b": "{props.gap}"}), ctx), Some(json!({"b": "lg"})));
745            assert_eq!(ev(json!(["{props.missing}", "{props.gap}"]), ctx), Some(json!(["lg"])));
746            assert_eq!(ev(json!({"@media (min-width: $breakpoint.{props.gap})": {"x": 1}}), ctx), Some(json!({"@media (min-width: $breakpoint.lg)": {"x": 1}})));
747        });
748    }
749
750    #[test]
751    fn interpolation_keeps_unmatched_braces() {
752        with_ctx(|ctx| {
753            assert_eq!(ev(json!("a{}b{{props.gap}}"), ctx), Some(json!("a{}b{lg}")));
754            assert_eq!(ev(json!("{props.missing}x{"), ctx), Some(json!("x{")));
755        });
756    }
757
758    #[test]
759    fn bound_inputs_emit_calls_member_vs_operand() {
760        with_bound(|ctx| {
761            // A member passes the binding through (two-way binding survives),
762            // the fallback is dropped.
763            assert_eq!(ev(json!("{props.page}"), ctx), Some(json!({"path": "/page"})));
764            assert_eq!(ev(json!("{props.page|1}"), ctx), Some(json!({"path": "/page"})));
765            // An operand keeps it.
766            assert_eq!(
767                ev(json!({"$eq": ["{props.page|1}", 1]}), ctx),
768                Some(json!({"call": "eq", "args": {"a": {"call": "fallback", "args": {"value": {"path": "/page"}, "default": 1}}, "b": 1}}))
769            );
770            assert_eq!(ev(json!("{props.page} / {props.total}"), ctx), Some(json!({"call": "concat", "args": {"values": [{"path": "/page"}, " / ", 5]}})));
771            assert_eq!(ev(json!("{!props.open}"), ctx), Some(json!({"call": "not", "args": {"value": {"path": "/open"}}})));
772            assert_eq!(ev(json!("{range(props.page)}"), ctx), None);
773            assert_eq!(ev(json!({"$percent": ["{props.page}", 10]}), ctx), Some(json!({"call": "percent", "args": {"value": {"path": "/page"}, "max": 10}})));
774            assert_eq!(ev(json!({"$cond": ["props.open", "a", "b"]}), ctx), Some(json!({"call": "cond", "args": {"if": {"path": "/open"}, "then": "a", "else": "b"}})));
775            assert_eq!(
776                ev(json!({"$coalesce": ["", "{props.page}", "x", "y"]}), ctx),
777                Some(json!({"call": "coalesce", "args": {"values": [{"path": "/page"}, "x"]}}))
778            );
779            assert_eq!(ev(json!({"$text": "props.page"}), ctx), Some(json!({"call": "text", "args": {"value": {"path": "/page"}}})));
780            assert_eq!(
781                ev(json!({"$clamp": ["{props.page|1}", 1, "{props.total}"]}), ctx),
782                Some(json!({"call": "clamp", "args": {"value": {"call": "fallback", "args": {"value": {"path": "/page"}, "default": 1}}, "min": 1, "max": 5}}))
783            );
784            assert_eq!(eval_condition_value(Some(&json!("props.open")), ctx), Decided::Dynamic(json!({"path": "/open"})));
785            assert!(eval_condition(&json!("props.open"), ctx));
786        });
787    }
788
789    #[test]
790    fn core_functions_evaluate_like_the_reference() {
791        let args = |v: Value| v.as_object().unwrap().clone();
792        assert_eq!(core_function("percent", &args(json!({"value": 1, "max": 3}))), Some(Some(json!("33.33%"))));
793        assert_eq!(core_function("percent", &args(json!({"value": 1, "max": 0}))), Some(Some(json!("0%"))));
794        assert_eq!(core_function("add", &args(json!({"a": "x", "b": 2}))), Some(Some(json!(2))));
795        assert_eq!(core_function("fallback", &args(json!({"default": 1}))), Some(Some(json!(1))));
796        assert_eq!(core_function("concat", &args(json!({"values": ["a", null, 1]}))), Some(Some(json!("a1"))));
797        assert_eq!(core_function("coalesce", &args(json!({"values": ["", 0]}))), Some(Some(json!(0))));
798        assert_eq!(core_function("text", &args(json!({}))), Some(None));
799        assert_eq!(core_function("map", &args(json!({"value": 2, "cases": {"2": "two"}, "default": "?"}))), Some(Some(json!("two"))));
800        assert_eq!(core_function("len", &args(json!({"value": "héllo"}))), Some(Some(json!(5))));
801        assert_eq!(core_function("eq", &args(json!({"a": 5, "b": " 5 "}))), Some(Some(json!(true))));
802        assert_eq!(core_function("eq", &args(json!({"a": 0, "b": ""}))), Some(Some(json!(false))));
803        assert_eq!(core_function("eq", &args(json!({"a": 1, "b": true}))), Some(Some(json!(false))));
804        assert_eq!(core_function("clamp", &args(json!({"value": 9, "min": 1, "max": 5}))), Some(Some(json!(5))));
805        assert_eq!(core_function("clamp", &args(json!({"value": -2, "min": 1, "max": null}))), Some(Some(json!(1))));
806        assert_eq!(core_function("clamp", &args(json!({"value": 3, "min": 4, "max": 2}))), Some(Some(json!(4))));
807        assert_eq!(core_function("fill", &args(json!({"template": "Page {page} of {total}", "params": {"page": 2}}))), Some(Some(json!("Page 2 of {total}"))));
808        assert_eq!(core_function("nope", &args(json!({}))), None);
809    }
810
811    /// R8 F51: `range(n)` stops at MAX_RANGE_ITEMS; NaN and negatives are empty.
812    #[test]
813    fn range_is_clamped() {
814        let props = json!({"huge": 1e8, "vast": 1e300, "neg": -3, "nan": "x"});
815        let props = props.as_object().unwrap().clone();
816        let ctx = ExprContext { id: "r", props: &props, vars: IndexMap::new() };
817        let len = |e: &str| ev(json!(e), &ctx).and_then(|v| v.as_array().map(Vec::len));
818        assert_eq!(len("{range(props.huge)}"), Some(MAX_RANGE_ITEMS));
819        assert_eq!(len("{range(props.vast)}"), Some(MAX_RANGE_ITEMS));
820        assert_eq!(len("{range(props.neg)}"), Some(0));
821        assert_eq!(len("{range(props.nan)}"), Some(0));
822        assert_eq!(len("{range(props.missing|5)}"), Some(5));
823    }
824
825}