1use indexmap::IndexMap;
20use serde_json::{Map, Value};
21
22use crate::json;
23use crate::types::Props;
24
25#[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
34pub 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
39pub 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
44pub 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
53pub fn truthy(value: &Value) -> bool {
56 !matches!(value, Value::Null | Value::Bool(false)) && value.as_str() != Some("")
57}
58
59pub fn truthy_opt(value: Option<&Value>) -> bool {
61 value.is_some_and(truthy)
62}
63
64pub 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
98fn 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
140pub 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
156fn 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
165pub 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
177pub 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
190pub 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
202fn 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
211fn 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
223pub const MAX_RANGE_ITEMS: usize = 10_000;
229
230pub 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 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
270fn 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
283fn 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#[derive(Debug, Clone, PartialEq)]
297pub enum Decided {
298 Bool(bool),
299 Dynamic(Value),
300}
301
302pub 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
315pub fn eval_condition(cond: &Value, ctx: &ExprContext) -> bool {
318 !matches!(eval_condition_value(Some(cond), ctx), Decided::Bool(false))
319}
320
321fn 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
364fn 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
374fn 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
381pub 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 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
543pub 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
553pub fn js_round(v: f64) -> f64 {
555 (v + 0.5).floor()
556}
557
558pub 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
612pub const CORE_FUNCTION_NAMES: &[&str] = &["percent", "add", "sub", "eq", "lt", "clamp", "cond", "fallback", "concat", "coalesce", "text", "map", "len", "fill", "set", "filter"];
615
616pub 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 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 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 #[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}