1use serde::{Deserialize, Serialize};
16use serde_json::{Map, Value};
17
18use crate::expr::truthy;
19use crate::format::Formatter;
20use crate::json;
21use crate::limits;
22use crate::strings::{resolve_string, StringTable};
23use crate::catalog::CatalogView;
24use crate::types::{ComponentDef, DefSchema, PropSchema, UiNode};
25
26pub use crate::expr::{is_binding, is_call, is_dynamic};
27
28fn unescape(token: &str) -> String {
29 token.replace("~1", "/").replace("~0", "~")
30}
31
32pub fn pointer_tokens(pointer: &str) -> Vec<String> {
34 if pointer.is_empty() || pointer == "/" {
35 return Vec::new();
36 }
37 pointer.strip_prefix('/').unwrap_or(pointer).split('/').map(unescape).collect()
38}
39
40pub fn absolute_path(path: &str, scope: &str) -> String {
43 if path.starts_with('/') {
44 return path.to_string();
45 }
46 if path.is_empty() {
47 return scope.to_string();
48 }
49 format!("{scope}/{path}")
50}
51
52pub fn get_pointer<'a>(data: &'a Value, pointer: &str) -> Option<&'a Value> {
53 if pointer.is_empty() || pointer == "/" {
54 return Some(data);
55 }
56 let mut cur = data;
59 for raw in pointer.strip_prefix('/').unwrap_or(pointer).split('/') {
60 let token: std::borrow::Cow<str> = if raw.contains('~') { unescape(raw).into() } else { raw.into() };
61 let token = token.as_ref();
62 cur = match cur {
63 Value::Array(items) => items.get(read_index(token, items.len())?)?,
64 Value::Object(map) => map.get(token)?,
65 _ => return None,
66 };
67 }
68 Some(cur)
69}
70
71fn read_index(token: &str, len: usize) -> Option<usize> {
76 if token == "-" {
77 return len.checked_sub(1);
78 }
79 if token.is_empty() || !token.bytes().all(|b| b.is_ascii_digit()) {
80 return None;
81 }
82 token.parse::<usize>().ok()
83}
84
85pub fn write_tokens(pointer: &str) -> Result<Vec<String>, String> {
88 if pointer.len() > limits::MAX_POINTER_BYTES {
89 return Err(format!("data: pointer longer than {} bytes", limits::MAX_POINTER_BYTES));
90 }
91 if pointer.is_empty() || pointer == "/" {
92 return Ok(Vec::new());
93 }
94 let body = pointer.strip_prefix('/').unwrap_or(pointer);
95 if body.split('/').count() > limits::MAX_POINTER_SEGMENTS {
96 return Err(format!("data: pointer has more than {} segments", limits::MAX_POINTER_SEGMENTS));
97 }
98 Ok(body.split('/').map(unescape).collect())
99}
100
101fn array_index(token: &str, len: usize) -> Result<usize, String> {
105 if token == "-" {
106 return Ok(len);
107 }
108 if token.is_empty() || !token.bytes().all(|b| b.is_ascii_digit()) {
109 return Err(format!("data: {} is not an array index", Value::String(token.to_string())));
110 }
111 match token.parse::<usize>() {
112 Ok(i) if i <= len => Ok(i),
113 _ => Err(format!("data: index {token} is past the end of the array ({len} items)")),
114 }
115}
116
117pub fn set_pointer(data: &mut Value, pointer: &str, value: Option<Value>) -> Result<(), String> {
123 let tokens = write_tokens(pointer)?;
124 let Some((last, path)) = tokens.split_last() else {
125 *data = value.unwrap_or_else(|| Value::Object(Map::new()));
126 return Ok(());
127 };
128 let mut probe = Some(&*data);
131 for token in &tokens {
132 match probe {
133 Some(Value::Array(items)) => {
134 let i = array_index(token, items.len())?;
135 probe = items.get(i);
136 }
137 Some(Value::Object(map)) => probe = map.get(token),
138 _ => break,
139 }
140 }
141 let mut cur = data;
142 for token in path {
143 if !(cur.is_object() || cur.is_array()) {
144 *cur = Value::Object(Map::new());
145 }
146 cur = match cur {
147 Value::Array(items) => {
148 let i = array_index(token, items.len())?;
149 if i == items.len() {
150 items.push(Value::Null);
151 }
152 &mut items[i]
153 }
154 Value::Object(map) => map.entry(token.clone()).or_insert(Value::Null),
155 _ => unreachable!(),
156 };
157 }
158 if !(cur.is_object() || cur.is_array()) {
159 *cur = Value::Object(Map::new());
160 }
161 match cur {
162 Value::Array(items) => {
163 let i = array_index(last, items.len())?;
164 match value {
165 None => {
166 if i < items.len() {
167 items.remove(i);
168 }
169 }
170 Some(v) if i == items.len() => items.push(v),
171 Some(v) => items[i] = v,
172 }
173 }
174 Value::Object(map) => match value {
175 None => {
176 map.remove(last);
177 }
178 Some(v) => {
179 map.insert(last.clone(), v);
180 }
181 },
182 _ => unreachable!(),
183 }
184 Ok(())
185}
186
187#[derive(Clone, Copy)]
189pub struct ResolveContext<'a> {
190 pub data: &'a Value,
191 pub scope: &'a str,
193 pub strings: Option<&'a StringTable>,
196 pub overlay: Option<(&'a str, &'a Value)>,
199 pub item: Option<&'a Value>,
203 pub view: Option<&'a CatalogView>,
206 pub formatter: Option<&'a dyn Formatter>,
210 pub now: Option<f64>,
213}
214
215impl<'a> ResolveContext<'a> {
216 pub fn new(data: &'a Value, scope: &'a str) -> ResolveContext<'a> {
217 ResolveContext { data, scope, strings: None, overlay: None, item: None, view: None, formatter: None, now: None }
218 }
219
220 pub fn with_formatter(self, formatter: &'a dyn Formatter) -> ResolveContext<'a> {
222 ResolveContext { formatter: Some(formatter), ..self }
223 }
224
225 pub fn with_now(self, now_ms: f64) -> ResolveContext<'a> {
228 ResolveContext { now: Some(now_ms), ..self }
229 }
230
231 pub fn formatter(&self) -> &'a dyn Formatter {
233 self.formatter.unwrap_or(&crate::format::ENGLISH)
234 }
235
236 pub fn with_item(self, item: &'a Value) -> ResolveContext<'a> {
238 ResolveContext { item: Some(item), ..self }
239 }
240
241 pub fn with_view(self, view: &'a CatalogView) -> ResolveContext<'a> {
243 ResolveContext { view: Some(view), ..self }
244 }
245
246 pub fn read_path(&self, path: &str) -> Option<&'a Value> {
249 if !path.starts_with('/') {
250 if let Some(item) = self.item {
251 return if path.is_empty() { Some(item) } else { get_pointer(item, &format!("/{path}")) };
252 }
253 }
254 self.read(&absolute_path(path, self.scope))
255 }
256
257 pub fn with_strings(self, strings: &'a StringTable) -> ResolveContext<'a> {
258 ResolveContext { strings: Some(strings), ..self }
259 }
260
261 pub fn with_overlay(self, prefix: &'a str, value: &'a Value) -> ResolveContext<'a> {
262 ResolveContext { overlay: Some((prefix, value)), ..self }
263 }
264
265 pub fn read(&self, pointer: &str) -> Option<&'a Value> {
268 if let Some((prefix, value)) = self.overlay {
269 if let Some(rest) = pointer.strip_prefix(prefix) {
270 if rest.is_empty() || rest.starts_with('/') {
271 return get_pointer(value, rest);
272 }
273 }
274 }
275 get_pointer(self.data, pointer)
276 }
277}
278
279
280fn num(v: Option<&Value>) -> f64 {
281 v.map(json::to_number).unwrap_or(f64::NAN)
282}
283
284fn interpolate(text: &str, ctx: &ResolveContext) -> String {
285 let mut out = String::new();
286 let mut rest = text;
287 while let Some(start) = rest.find("${") {
288 out.push_str(&rest[..start]);
289 let after = &rest[start + 2..];
290 match after.find('}') {
291 Some(end) => {
292 let expr = after[..end].trim();
293 let v = ctx.read(&absolute_path(expr, ctx.scope));
294 if let Some(v) = v.filter(|v| !v.is_null()) {
295 out.push_str(&json::to_js_string(v));
296 }
297 rest = &after[end + 1..];
298 }
299 None => {
300 out.push_str(&rest[start..]);
301 rest = "";
302 }
303 }
304 }
305 out.push_str(rest);
306 out
307}
308
309pub fn call_function(name: &str, args: &Map<String, Value>, ctx: &ResolveContext) -> Option<Value> {
316 if let Some(result) = crate::format::call_format_function(name, args, ctx.formatter(), ctx.now.unwrap_or_else(crate::format::now_ms)) {
317 return result;
318 }
319 if crate::cel::CALL_NAMES.contains(&name) {
322 let formatter = ctx.formatter();
323 let offset = move |ms: f64| formatter.offset_minutes(ms);
324 let env = crate::cel::ExpressionEnv { now: ctx.now, offset: Some(&offset), max_steps: None };
325 if let Some(result) = crate::cel::call_expression_function(name, args, &env) {
326 return result;
327 }
328 }
329 if let Some(result) = crate::expr::core_function(name, args) {
330 return result;
331 }
332 let a = |k: &str| args.get(k);
333 let s = |k: &str| a(k).filter(|v| !v.is_null()).map(json::to_js_string).unwrap_or_default();
334 Some(match name {
335 "required" => Value::Bool(a("value").is_some_and(|v| truthy(v) && !v.as_array().is_some_and(Vec::is_empty))),
336 "regex" => Value::Bool(regex_test(&s("pattern"), &s("value"))),
337 "length" => {
338 let n = s("value").chars().count() as f64;
339 Value::Bool(a("min").is_none_or(|m| n >= json::to_number(m)) && a("max").is_none_or(|m| n <= json::to_number(m)))
340 }
341 "numeric" => {
342 let n = num(a("value"));
343 Value::Bool(n.is_finite() && a("min").is_none_or(|m| n >= json::to_number(m)) && a("max").is_none_or(|m| n <= json::to_number(m)))
344 }
345 "email" => {
346 let v = s("value");
347 let ok = v.split_once('@').is_some_and(|(local, domain)| {
348 !local.is_empty() && !local.contains(char::is_whitespace) && domain.contains('.') && !domain.contains('@') && !domain.starts_with('.') && !domain.ends_with('.') && !domain.contains(char::is_whitespace)
349 });
350 Value::Bool(ok)
351 }
352 "formatString" => Value::String(interpolate(&s("value"), ctx)),
353 "openUrl" => return a("url").filter(|v| v.is_string()).cloned(),
354 "and" => Value::Bool(a("values").and_then(Value::as_array).is_some_and(|v| v.iter().all(truthy))),
355 "or" => Value::Bool(a("values").and_then(Value::as_array).is_some_and(|v| v.iter().any(truthy))),
356 "not" => Value::Bool(!a("value").is_some_and(truthy)),
357 _ => return None,
358 })
359}
360
361
362fn regex_test(pattern: &str, value: &str) -> bool {
368 simple_regex::is_match(pattern, value)
369}
370
371mod simple_regex {
372 #[derive(Debug, Clone)]
373 enum Atom {
374 Any,
375 Char(char),
376 Class(Vec<(char, char)>, bool),
377 }
378 #[derive(Debug, Clone)]
379 struct Piece {
380 atom: Atom,
381 min: usize,
382 max: Option<usize>,
383 }
384
385 fn parse(pattern: &str) -> Option<(bool, bool, Vec<Piece>)> {
386 let mut chars: Vec<char> = pattern.chars().collect();
387 let anchored_start = chars.first() == Some(&'^');
388 if anchored_start {
389 chars.remove(0);
390 }
391 let anchored_end = chars.last() == Some(&'$');
392 if anchored_end {
393 chars.pop();
394 }
395 let mut pieces = Vec::new();
396 let mut i = 0;
397 while i < chars.len() {
398 let atom = match chars[i] {
399 '.' => Atom::Any,
400 '\\' => {
401 i += 1;
402 match chars.get(i)? {
403 'd' => Atom::Class(vec![('0', '9')], false),
404 'w' => Atom::Class(vec![('a', 'z'), ('A', 'Z'), ('0', '9'), ('_', '_')], false),
405 's' => Atom::Class(vec![(' ', ' '), ('\t', '\t'), ('\n', '\n'), ('\r', '\r')], false),
406 c => Atom::Char(*c),
407 }
408 }
409 '[' => {
410 i += 1;
411 let negated = chars.get(i) == Some(&'^');
412 if negated {
413 i += 1;
414 }
415 let mut ranges = Vec::new();
416 while i < chars.len() && chars[i] != ']' {
417 let mut c = chars[i];
418 if c == '\\' {
419 i += 1;
420 c = *chars.get(i)?;
421 }
422 if chars.get(i + 1) == Some(&'-') && chars.get(i + 2).is_some_and(|e| *e != ']') {
423 ranges.push((c, chars[i + 2]));
424 i += 3;
425 } else {
426 ranges.push((c, c));
427 i += 1;
428 }
429 }
430 if i >= chars.len() {
431 return None;
432 }
433 Atom::Class(ranges, negated)
434 }
435 '(' | ')' | '|' => return None,
436 c => Atom::Char(c),
437 };
438 i += 1;
439 let (min, max) = match chars.get(i) {
440 Some('*') => {
441 i += 1;
442 (0, None)
443 }
444 Some('+') => {
445 i += 1;
446 (1, None)
447 }
448 Some('?') => {
449 i += 1;
450 (0, Some(1))
451 }
452 Some('{') => {
453 let close = chars[i..].iter().position(|c| *c == '}')? + i;
454 let body: String = chars[i + 1..close].iter().collect();
455 i = close + 1;
456 match body.split_once(',') {
457 Some((a, b)) => (a.trim().parse().ok()?, if b.trim().is_empty() { None } else { Some(b.trim().parse().ok()?) }),
458 None => {
459 let n: usize = body.trim().parse().ok()?;
460 (n, Some(n))
461 }
462 }
463 }
464 _ => (1, Some(1)),
465 };
466 pieces.push(Piece { atom, min, max });
467 }
468 Some((anchored_start, anchored_end, pieces))
469 }
470
471 fn matches_atom(atom: &Atom, c: char) -> bool {
472 match atom {
473 Atom::Any => c != '\n',
474 Atom::Char(x) => *x == c,
475 Atom::Class(ranges, negated) => ranges.iter().any(|(a, b)| *a <= c && c <= *b) != *negated,
476 }
477 }
478
479 fn match_here(pieces: &[Piece], text: &[char], end_anchor: bool) -> bool {
480 let Some(piece) = pieces.first() else {
481 return !end_anchor || text.is_empty();
482 };
483 let max = piece.max.unwrap_or(text.len());
484 let mut count = 0;
485 while count < max && count < text.len() && matches_atom(&piece.atom, text[count]) {
486 count += 1;
487 }
488 if count < piece.min {
489 return false;
490 }
491 let mut n = count;
492 loop {
493 if match_here(&pieces[1..], &text[n..], end_anchor) {
494 return true;
495 }
496 if n == piece.min {
497 return false;
498 }
499 n -= 1;
500 }
501 }
502
503 pub fn is_match(pattern: &str, value: &str) -> bool {
504 let Some((start, end, pieces)) = parse(pattern) else { return false };
505 let text: Vec<char> = value.chars().collect();
506 if start {
507 return match_here(&pieces, &text, end);
508 }
509 (0..=text.len()).any(|i| match_here(&pieces, &text[i..], end))
510 }
511}
512
513pub fn resolve_value(value: &Value, ctx: &ResolveContext) -> Option<Value> {
518 if is_binding(value) {
519 return ctx.read_path(value["path"].as_str().unwrap_or("")).cloned();
520 }
521 if is_call(value) {
522 let name = value["call"].as_str().unwrap_or("");
523 let mut args = Map::new();
524 if let Some(raw) = value.get("args").and_then(Value::as_object) {
525 for (k, v) in raw {
526 if let Some(r) = resolve_value(v, ctx) {
527 args.insert(k.clone(), r);
528 }
529 }
530 }
531 if name == "expr" {
534 if let (Some(raw), Some(Value::Object(vars))) = (value.get("args").and_then(|a| a.get("vars")).and_then(Value::as_object), args.get_mut("vars")) {
535 for k in raw.keys() {
536 vars.entry(k.clone()).or_insert(Value::Null);
537 }
538 }
539 }
540 return call_function(name, &args, ctx).or_else(|| args.get("default").cloned());
543 }
544 match value {
545 Value::String(s) if ctx.strings.is_some() => Some(Value::String(resolve_string(s, ctx.strings.expect("strings")).to_string())),
546 Value::Array(items) => Some(Value::Array(items.iter().map(|i| resolve_value(i, ctx).unwrap_or(Value::Null)).collect())),
547 Value::Object(map) => {
548 let mut out = Map::new();
549 for (k, v) in map {
550 if let Some(r) = resolve_value(v, ctx) {
551 out.insert(k.clone(), r);
552 }
553 }
554 Some(Value::Object(out))
555 }
556 other => Some(other.clone()),
557 }
558}
559
560pub fn binding_paths(value: &Value, scope: &str, out: &mut Vec<String>) {
562 if is_binding(value) {
563 out.push(absolute_path(value["path"].as_str().unwrap_or(""), scope));
564 return;
565 }
566 match value {
567 Value::Array(items) => items.iter().for_each(|i| binding_paths(i, scope, out)),
568 Value::Object(map) => map.values().for_each(|v| binding_paths(v, scope, out)),
569 _ => {}
570 }
571}
572
573pub fn is_visible(visible: Option<&Value>, ctx: &ResolveContext) -> bool {
579 match visible {
580 None => true,
581 Some(v) => resolve_value(v, ctx).is_some_and(|r| truthy(&r)),
582 }
583}
584
585pub fn is_data_schema(schema: Option<&PropSchema>) -> bool {
591 match schema {
592 Some(s) if s.type_ == "object" => s.shape.is_none(),
593 Some(s) if s.type_ == "array" => s.items.as_deref().is_some_and(|items| is_data_schema(Some(items))),
594 _ => false,
595 }
596}
597
598pub fn resolve_prop(value: &Value, schema: Option<&PropSchema>, ctx: &ResolveContext, defs: &indexmap::IndexMap<String, DefSchema>) -> Option<Value> {
602 if is_dynamic(value) {
603 return resolve_value(value, ctx);
604 }
605 if is_data_schema(schema) {
606 return Some(value.clone());
607 }
608 if let (Some(s), Value::Array(items)) = (schema, value) {
609 if s.type_ == "array" {
610 if let Some(item_schema) = s.items.as_deref() {
611 return Some(Value::Array(items.iter().map(|i| resolve_prop(i, Some(item_schema), ctx, defs).unwrap_or(Value::Null)).collect()));
612 }
613 }
614 }
615 let shape = schema.filter(|s| s.type_ == "object").and_then(|s| s.shape.as_ref()).and_then(|name| defs.get(name));
616 if let (Some(shape), Value::Object(map)) = (shape, value) {
617 let mut out = Map::new();
618 for (k, v) in map {
619 if let Some(r) = resolve_prop(v, shape.properties.get(k), ctx, defs) {
620 out.insert(k.clone(), r);
621 }
622 }
623 return Some(Value::Object(out));
624 }
625 resolve_value(value, ctx)
626}
627
628pub fn resolve_node_props(def: Option<&ComponentDef>, props: &Map<String, Value>, ctx: &ResolveContext, defs: &indexmap::IndexMap<String, DefSchema>) -> Map<String, Value> {
631 let mut out = Map::new();
632 for (k, v) in props {
633 if let Some(r) = resolve_prop(v, def.and_then(|d| d.props.get(k)), ctx, defs) {
634 out.insert(k.clone(), r);
635 }
636 }
637 out
638}
639
640pub fn bind_tree(node: &UiNode, ctx: &ResolveContext) -> Option<UiNode> {
649 match ctx.view {
650 Some(view) => bind_node(node, ctx, view),
651 None => bind_node(node, ctx, &CatalogView::core()),
652 }
653}
654
655fn bind_node(node: &UiNode, ctx: &ResolveContext, view: &CatalogView) -> Option<UiNode> {
656 if !is_visible(node.visible.as_ref(), ctx) {
657 return None;
658 }
659 let resolve_map = |m: &Map<String, Value>| match resolve_value(&Value::Object(m.clone()), ctx) {
660 Some(Value::Object(out)) => out,
661 _ => Map::new(),
662 };
663 let def = view.components.get(&node.component);
664 let mut out = UiNode::new(node.id.clone(), node.component.clone());
665 out.props = resolve_node_props(def, &node.props, ctx, &view.defs);
666 out.style = node.style.as_ref().map(resolve_map);
667 out.on = node.on.clone();
668 out.accessibility = match node.accessibility.as_ref().map(|a| resolve_value(a, ctx)) {
669 Some(Some(Value::Object(a))) if !a.is_empty() => Some(Value::Object(a)),
670 _ => None,
671 };
672 out.children = node.children.iter().filter_map(|c| bind_node(c, ctx, view)).collect();
673 if let Some(slots) = &node.slots {
674 if def.is_some_and(ComponentDef::has_row_slots) {
675 out.slots = Some(slots.clone());
676 } else {
677 let bound: indexmap::IndexMap<String, UiNode> = slots.iter().filter_map(|(k, c)| bind_node(c, ctx, view).map(|b| (k.clone(), b))).collect();
678 if !bound.is_empty() {
679 out.slots = Some(bound);
680 }
681 }
682 }
683 out.template = node.template.clone();
684 out.recipe = node.recipe.as_ref().map(|r| crate::types::Recipe { macro_: r.macro_.clone(), part: r.part.clone(), props: resolve_map(&r.props), authored: r.authored.clone() });
685 Some(out)
686}
687
688pub fn bind_row_slot(slot: &UiNode, rows_prop: &Value, rows: &[Value], index: usize, ctx: &ResolveContext) -> Option<UiNode> {
695 if is_binding(rows_prop) {
696 let base = format!("{}/{index}", absolute_path(rows_prop["path"].as_str().unwrap_or(""), ctx.scope));
697 let row_ctx = ResolveContext { scope: &base, item: None, ..*ctx };
698 return bind_tree(slot, &row_ctx);
699 }
700 let null = Value::Null;
701 let row_ctx = ResolveContext { item: Some(rows.get(index).unwrap_or(&null)), ..*ctx };
702 bind_tree(slot, &row_ctx)
703}
704
705pub fn bind_section_header(slot: &UiNode, section: &crate::list::ListSection, index: usize, ctx: &ResolveContext) -> Option<UiNode> {
709 let item = serde_json::json!({"value": section.value, "count": section.count, "index": index});
710 let section_ctx = ResolveContext { item: Some(&item), ..*ctx };
711 bind_tree(slot, §ion_ctx)
712}
713
714#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
716pub struct ActionEvent {
717 pub name: String,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub context: Option<Value>,
720}
721
722#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
724pub struct ActionCall {
725 pub call: String,
726 pub args: Map<String, Value>,
727}
728
729#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
731pub struct ActionOutcome {
732 pub data: Value,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub event: Option<ActionEvent>,
736 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub call: Option<ActionCall>,
738 #[serde(skip)]
741 pub written: Option<(String, Value)>,
742 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub error: Option<String>,
745}
746
747pub fn run_action(action: &Value, ctx: &ResolveContext) -> ActionOutcome {
751 let mut out = ActionOutcome { data: ctx.data.clone(), event: None, call: None, written: None, error: None };
752 let event = action.get("event").filter(|e| e.is_object()).map(|e| ActionEvent {
753 name: e.get("name").and_then(Value::as_str).unwrap_or("").to_string(),
754 context: e.get("context").filter(|c| !c.is_null()).map(|c| resolve_value(c, ctx).unwrap_or(Value::Null)),
755 });
756 if let Some(function) = action.get("functionCall").filter(|f| f.is_object()) {
758 let name = function.get("call").and_then(Value::as_str).unwrap_or("").to_string();
759 let args = match resolve_value(function.get("args").unwrap_or(&Value::Object(Map::new())), ctx) {
760 Some(Value::Object(a)) => a,
761 _ => Map::new(),
762 };
763 match (name.as_str(), args.get("path").and_then(Value::as_str)) {
764 ("set", Some(path)) if !path.starts_with('/') && ctx.item.is_some() => {}
766 ("set", None) => {}
767 ("set", Some(path)) => {
768 let pointer = absolute_path(path, ctx.scope);
769 let value = args.get("value").cloned();
770 let written = set_pointer(&mut out.data, &pointer, Some(value.clone().unwrap_or(Value::Null))).and_then(|()| match value {
773 None => set_pointer(&mut out.data, &pointer, None),
774 Some(_) => Ok(()),
775 });
776 match written {
777 Ok(()) => out.written = Some((pointer, value.unwrap_or(Value::Null))),
778 Err(e) => out.error = Some(e),
779 }
780 }
781 _ => out.call = Some(ActionCall { call: name, args }),
782 }
783 }
784 out.event = event;
785 out
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791 use serde_json::json;
792
793 #[test]
794 fn pointers_get_and_set() {
795 let mut data = json!({"user": {"name": "Ada"}, "items": [1, 2]});
796 assert_eq!(get_pointer(&data, "/user/name"), Some(&json!("Ada")));
797 assert_eq!(get_pointer(&data, "/items/1"), Some(&json!(2)));
798 set_pointer(&mut data, "/user/age", Some(json!(36))).unwrap();
799 set_pointer(&mut data, "/items/-", Some(json!(3))).unwrap();
800 set_pointer(&mut data, "/items/0", None).unwrap();
801 assert_eq!(data, json!({"user": {"name": "Ada", "age": 36}, "items": [2, 3]}));
802 set_pointer(&mut data, "/new/deep/key", Some(json!(true))).unwrap();
803 assert_eq!(get_pointer(&data, "/new/deep/key"), Some(&json!(true)));
804 assert_eq!(absolute_path("name", "/items/3"), "/items/3/name");
805 assert_eq!(absolute_path("", "/items/3"), "/items/3");
806 assert_eq!(absolute_path("/x", "/items/3"), "/x");
807 assert_eq!(absolute_path("x", ""), "/x");
808 assert_eq!(absolute_path("", ""), "");
809 let mut fresh = json!({});
810 set_pointer(&mut fresh, "/a/0/b", Some(json!(1))).unwrap();
811 assert_eq!(fresh, json!({"a": {"0": {"b": 1}}}), "missing containers are objects (writePointer)");
812 }
813
814 #[test]
815 fn functions_resolve() {
816 let data = json!({"n": 3, "email": "a@b.co", "price": 1234.5});
817 let ctx = ResolveContext::new(&data, "");
818 let call = |name: &str, args: Value| resolve_value(&json!({"call": name, "args": args}), &ctx);
819 assert_eq!(call("required", json!({"value": {"path": "/email"}})), Some(json!(true)));
820 assert_eq!(call("email", json!({"value": {"path": "/email"}})), Some(json!(true)));
821 assert_eq!(call("regex", json!({"value": "ab12", "pattern": "^[a-z]+\\d{2}$"})), Some(json!(true)));
822 assert_eq!(call("regex", json!({"value": "ab1", "pattern": "^[a-z]+\\d{2}$"})), Some(json!(false)));
823 assert_eq!(call("formatNumber", json!({"value": {"path": "/price"}})), Some(json!("1,234.5")));
824 assert_eq!(call("formatPercent", json!({"value": 0.256})), Some(json!("26%")));
825 assert_eq!(call("formatNumber", json!({"value": 1234.5, "decimals": 2})), Some(json!("1,234.50")));
826 assert_eq!(call("formatCurrency", json!({"value": 1234.5, "currency": "USD"})), Some(json!("$1,234.50")));
827 assert_eq!(call("formatString", json!({"value": "n = ${/n}"})), Some(json!("n = 3")));
828 assert_eq!(call("pluralize", json!({"value": {"path": "/n"}, "one": "item", "other": "items"})), Some(json!("items")));
829 assert_eq!(call("pluralize", json!({"value": 1, "other": "items"})), Some(json!("items")));
830 assert_eq!(call("pluralize", json!({"value": "x"})), None);
831 assert_eq!(call("formatDate", json!({"value": "2026-10-07", "format": "d MMM yyyy"})), Some(json!("7 Oct 2026")));
832 assert_eq!(call("formatDate", json!({"value": "2026-10-07", "format": "EEE"})), Some(json!("Wed")));
833 assert_eq!(call("and", json!({"values": [true, 1]})), Some(json!(true)));
834 assert_eq!(call("not", json!({"value": ""})), Some(json!(true)));
835 assert_eq!(call("length", json!({"value": "abc", "min": 2, "max": 3})), Some(json!(true)));
836 assert_eq!(call("numeric", json!({"value": "12", "min": 10})), Some(json!(true)));
837 }
838
839 #[test]
840 fn a_section_header_binds_its_section() {
841 let header: UiNode = serde_json::from_value(json!({"id": "h", "component": "Text", "props": {"text": {"call": "concat", "args": {"values": [{"path": "value"}, " · ", {"path": "count"}, " · ", {"path": "/title"}]}}}})).unwrap();
842 let data = json!({"title": "Inbox"});
843 let section = crate::list::ListSection { value: "Today".into(), start: 0, count: 2 };
844 let bound = bind_section_header(&header, §ion, 0, &ResolveContext::new(&data, "")).unwrap();
845 assert_eq!(bound.props["text"], json!("Today · 2 · Inbox"));
846 }
847
848 #[test]
849 fn a_bound_submenu_resolves_to_the_rows_of_its_source() {
850 let view = CatalogView::core();
853 let def = view.components.get("Menu").expect("Menu");
854 let props = json!({"items": [
855 {"label": "Rename", "value": "rename"},
856 {"kind": "submenu", "label": "Status", "items": {"path": "/statuses"}},
857 ]});
858 let props = props.as_object().unwrap();
859 assert_eq!(crate::validate::validate_props(def, props, "props", &view), vec![]);
860 let data = json!({"statuses": [{"label": "Backlog", "value": "backlog"}, {"label": "Done", "value": "done", "icon": "ui-check"}]});
861 let out = resolve_node_props(Some(def), props, &ResolveContext::new(&data, ""), &view.defs);
862 assert_eq!(out["items"][0], json!({"label": "Rename", "value": "rename"}));
863 assert_eq!(out["items"][1]["items"], data["statuses"]);
864 let empty = resolve_node_props(Some(def), props, &ResolveContext::new(&json!({}), ""), &view.defs);
866 assert!(empty["items"][1].get("items").is_none());
867 }
868
869 #[test]
870 fn template_scope_resolves_relative_paths() {
871 let data = json!({"items": [{"name": "one"}, {"name": "two"}]});
872 let ctx = ResolveContext::new(&data, "/items/1");
873 assert_eq!(resolve_value(&json!({"path": "name"}), &ctx), Some(json!("two")));
874 let mut paths = Vec::new();
875 binding_paths(&json!({"a": {"path": "name"}, "b": [{"path": "/x"}]}), "/items/1", &mut paths);
876 assert_eq!(paths, vec!["/items/1/name", "/x"]);
877 }
878}