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 let Some(result) = crate::expr::core_function(name, args) {
320 return result;
321 }
322 let a = |k: &str| args.get(k);
323 let s = |k: &str| a(k).filter(|v| !v.is_null()).map(json::to_js_string).unwrap_or_default();
324 Some(match name {
325 "required" => Value::Bool(a("value").is_some_and(|v| truthy(v) && !v.as_array().is_some_and(Vec::is_empty))),
326 "regex" => Value::Bool(regex_test(&s("pattern"), &s("value"))),
327 "length" => {
328 let n = s("value").chars().count() as f64;
329 Value::Bool(a("min").is_none_or(|m| n >= json::to_number(m)) && a("max").is_none_or(|m| n <= json::to_number(m)))
330 }
331 "numeric" => {
332 let n = num(a("value"));
333 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)))
334 }
335 "email" => {
336 let v = s("value");
337 let ok = v.split_once('@').is_some_and(|(local, domain)| {
338 !local.is_empty() && !local.contains(char::is_whitespace) && domain.contains('.') && !domain.contains('@') && !domain.starts_with('.') && !domain.ends_with('.') && !domain.contains(char::is_whitespace)
339 });
340 Value::Bool(ok)
341 }
342 "formatString" => Value::String(interpolate(&s("value"), ctx)),
343 "openUrl" => return a("url").filter(|v| v.is_string()).cloned(),
344 "and" => Value::Bool(a("values").and_then(Value::as_array).is_some_and(|v| v.iter().all(truthy))),
345 "or" => Value::Bool(a("values").and_then(Value::as_array).is_some_and(|v| v.iter().any(truthy))),
346 "not" => Value::Bool(!a("value").is_some_and(truthy)),
347 _ => return None,
348 })
349}
350
351
352fn regex_test(pattern: &str, value: &str) -> bool {
358 simple_regex::is_match(pattern, value)
359}
360
361mod simple_regex {
362 #[derive(Debug, Clone)]
363 enum Atom {
364 Any,
365 Char(char),
366 Class(Vec<(char, char)>, bool),
367 }
368 #[derive(Debug, Clone)]
369 struct Piece {
370 atom: Atom,
371 min: usize,
372 max: Option<usize>,
373 }
374
375 fn parse(pattern: &str) -> Option<(bool, bool, Vec<Piece>)> {
376 let mut chars: Vec<char> = pattern.chars().collect();
377 let anchored_start = chars.first() == Some(&'^');
378 if anchored_start {
379 chars.remove(0);
380 }
381 let anchored_end = chars.last() == Some(&'$');
382 if anchored_end {
383 chars.pop();
384 }
385 let mut pieces = Vec::new();
386 let mut i = 0;
387 while i < chars.len() {
388 let atom = match chars[i] {
389 '.' => Atom::Any,
390 '\\' => {
391 i += 1;
392 match chars.get(i)? {
393 'd' => Atom::Class(vec![('0', '9')], false),
394 'w' => Atom::Class(vec![('a', 'z'), ('A', 'Z'), ('0', '9'), ('_', '_')], false),
395 's' => Atom::Class(vec![(' ', ' '), ('\t', '\t'), ('\n', '\n'), ('\r', '\r')], false),
396 c => Atom::Char(*c),
397 }
398 }
399 '[' => {
400 i += 1;
401 let negated = chars.get(i) == Some(&'^');
402 if negated {
403 i += 1;
404 }
405 let mut ranges = Vec::new();
406 while i < chars.len() && chars[i] != ']' {
407 let mut c = chars[i];
408 if c == '\\' {
409 i += 1;
410 c = *chars.get(i)?;
411 }
412 if chars.get(i + 1) == Some(&'-') && chars.get(i + 2).is_some_and(|e| *e != ']') {
413 ranges.push((c, chars[i + 2]));
414 i += 3;
415 } else {
416 ranges.push((c, c));
417 i += 1;
418 }
419 }
420 if i >= chars.len() {
421 return None;
422 }
423 Atom::Class(ranges, negated)
424 }
425 '(' | ')' | '|' => return None,
426 c => Atom::Char(c),
427 };
428 i += 1;
429 let (min, max) = match chars.get(i) {
430 Some('*') => {
431 i += 1;
432 (0, None)
433 }
434 Some('+') => {
435 i += 1;
436 (1, None)
437 }
438 Some('?') => {
439 i += 1;
440 (0, Some(1))
441 }
442 Some('{') => {
443 let close = chars[i..].iter().position(|c| *c == '}')? + i;
444 let body: String = chars[i + 1..close].iter().collect();
445 i = close + 1;
446 match body.split_once(',') {
447 Some((a, b)) => (a.trim().parse().ok()?, if b.trim().is_empty() { None } else { Some(b.trim().parse().ok()?) }),
448 None => {
449 let n: usize = body.trim().parse().ok()?;
450 (n, Some(n))
451 }
452 }
453 }
454 _ => (1, Some(1)),
455 };
456 pieces.push(Piece { atom, min, max });
457 }
458 Some((anchored_start, anchored_end, pieces))
459 }
460
461 fn matches_atom(atom: &Atom, c: char) -> bool {
462 match atom {
463 Atom::Any => c != '\n',
464 Atom::Char(x) => *x == c,
465 Atom::Class(ranges, negated) => ranges.iter().any(|(a, b)| *a <= c && c <= *b) != *negated,
466 }
467 }
468
469 fn match_here(pieces: &[Piece], text: &[char], end_anchor: bool) -> bool {
470 let Some(piece) = pieces.first() else {
471 return !end_anchor || text.is_empty();
472 };
473 let max = piece.max.unwrap_or(text.len());
474 let mut count = 0;
475 while count < max && count < text.len() && matches_atom(&piece.atom, text[count]) {
476 count += 1;
477 }
478 if count < piece.min {
479 return false;
480 }
481 let mut n = count;
482 loop {
483 if match_here(&pieces[1..], &text[n..], end_anchor) {
484 return true;
485 }
486 if n == piece.min {
487 return false;
488 }
489 n -= 1;
490 }
491 }
492
493 pub fn is_match(pattern: &str, value: &str) -> bool {
494 let Some((start, end, pieces)) = parse(pattern) else { return false };
495 let text: Vec<char> = value.chars().collect();
496 if start {
497 return match_here(&pieces, &text, end);
498 }
499 (0..=text.len()).any(|i| match_here(&pieces, &text[i..], end))
500 }
501}
502
503pub fn resolve_value(value: &Value, ctx: &ResolveContext) -> Option<Value> {
508 if is_binding(value) {
509 return ctx.read_path(value["path"].as_str().unwrap_or("")).cloned();
510 }
511 if is_call(value) {
512 let name = value["call"].as_str().unwrap_or("");
513 let mut args = Map::new();
514 if let Some(raw) = value.get("args").and_then(Value::as_object) {
515 for (k, v) in raw {
516 if let Some(r) = resolve_value(v, ctx) {
517 args.insert(k.clone(), r);
518 }
519 }
520 }
521 return call_function(name, &args, ctx);
522 }
523 match value {
524 Value::String(s) if ctx.strings.is_some() => Some(Value::String(resolve_string(s, ctx.strings.expect("strings")).to_string())),
525 Value::Array(items) => Some(Value::Array(items.iter().map(|i| resolve_value(i, ctx).unwrap_or(Value::Null)).collect())),
526 Value::Object(map) => {
527 let mut out = Map::new();
528 for (k, v) in map {
529 if let Some(r) = resolve_value(v, ctx) {
530 out.insert(k.clone(), r);
531 }
532 }
533 Some(Value::Object(out))
534 }
535 other => Some(other.clone()),
536 }
537}
538
539pub fn binding_paths(value: &Value, scope: &str, out: &mut Vec<String>) {
541 if is_binding(value) {
542 out.push(absolute_path(value["path"].as_str().unwrap_or(""), scope));
543 return;
544 }
545 match value {
546 Value::Array(items) => items.iter().for_each(|i| binding_paths(i, scope, out)),
547 Value::Object(map) => map.values().for_each(|v| binding_paths(v, scope, out)),
548 _ => {}
549 }
550}
551
552pub fn is_visible(visible: Option<&Value>, ctx: &ResolveContext) -> bool {
558 match visible {
559 None => true,
560 Some(v) => resolve_value(v, ctx).is_some_and(|r| truthy(&r)),
561 }
562}
563
564pub fn is_data_schema(schema: Option<&PropSchema>) -> bool {
570 match schema {
571 Some(s) if s.type_ == "object" => s.shape.is_none(),
572 Some(s) if s.type_ == "array" => s.items.as_deref().is_some_and(|items| is_data_schema(Some(items))),
573 _ => false,
574 }
575}
576
577pub fn resolve_prop(value: &Value, schema: Option<&PropSchema>, ctx: &ResolveContext, defs: &indexmap::IndexMap<String, DefSchema>) -> Option<Value> {
581 if is_dynamic(value) {
582 return resolve_value(value, ctx);
583 }
584 if is_data_schema(schema) {
585 return Some(value.clone());
586 }
587 if let (Some(s), Value::Array(items)) = (schema, value) {
588 if s.type_ == "array" {
589 if let Some(item_schema) = s.items.as_deref() {
590 return Some(Value::Array(items.iter().map(|i| resolve_prop(i, Some(item_schema), ctx, defs).unwrap_or(Value::Null)).collect()));
591 }
592 }
593 }
594 let shape = schema.filter(|s| s.type_ == "object").and_then(|s| s.shape.as_ref()).and_then(|name| defs.get(name));
595 if let (Some(shape), Value::Object(map)) = (shape, value) {
596 let mut out = Map::new();
597 for (k, v) in map {
598 if let Some(r) = resolve_prop(v, shape.properties.get(k), ctx, defs) {
599 out.insert(k.clone(), r);
600 }
601 }
602 return Some(Value::Object(out));
603 }
604 resolve_value(value, ctx)
605}
606
607pub fn resolve_node_props(def: Option<&ComponentDef>, props: &Map<String, Value>, ctx: &ResolveContext, defs: &indexmap::IndexMap<String, DefSchema>) -> Map<String, Value> {
610 let mut out = Map::new();
611 for (k, v) in props {
612 if let Some(r) = resolve_prop(v, def.and_then(|d| d.props.get(k)), ctx, defs) {
613 out.insert(k.clone(), r);
614 }
615 }
616 out
617}
618
619pub fn bind_tree(node: &UiNode, ctx: &ResolveContext) -> Option<UiNode> {
628 match ctx.view {
629 Some(view) => bind_node(node, ctx, view),
630 None => bind_node(node, ctx, &CatalogView::core()),
631 }
632}
633
634fn bind_node(node: &UiNode, ctx: &ResolveContext, view: &CatalogView) -> Option<UiNode> {
635 if !is_visible(node.visible.as_ref(), ctx) {
636 return None;
637 }
638 let resolve_map = |m: &Map<String, Value>| match resolve_value(&Value::Object(m.clone()), ctx) {
639 Some(Value::Object(out)) => out,
640 _ => Map::new(),
641 };
642 let def = view.components.get(&node.component);
643 let mut out = UiNode::new(node.id.clone(), node.component.clone());
644 out.props = resolve_node_props(def, &node.props, ctx, &view.defs);
645 out.style = node.style.as_ref().map(resolve_map);
646 out.on = node.on.clone();
647 out.accessibility = match node.accessibility.as_ref().map(|a| resolve_value(a, ctx)) {
648 Some(Some(Value::Object(a))) if !a.is_empty() => Some(Value::Object(a)),
649 _ => None,
650 };
651 out.children = node.children.iter().filter_map(|c| bind_node(c, ctx, view)).collect();
652 if let Some(slots) = &node.slots {
653 if def.is_some_and(ComponentDef::has_row_slots) {
654 out.slots = Some(slots.clone());
655 } else {
656 let bound: indexmap::IndexMap<String, UiNode> = slots.iter().filter_map(|(k, c)| bind_node(c, ctx, view).map(|b| (k.clone(), b))).collect();
657 if !bound.is_empty() {
658 out.slots = Some(bound);
659 }
660 }
661 }
662 out.template = node.template.clone();
663 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() });
664 Some(out)
665}
666
667pub fn bind_row_slot(slot: &UiNode, rows_prop: &Value, rows: &[Value], index: usize, ctx: &ResolveContext) -> Option<UiNode> {
674 if is_binding(rows_prop) {
675 let base = format!("{}/{index}", absolute_path(rows_prop["path"].as_str().unwrap_or(""), ctx.scope));
676 let row_ctx = ResolveContext { scope: &base, item: None, ..*ctx };
677 return bind_tree(slot, &row_ctx);
678 }
679 let null = Value::Null;
680 let row_ctx = ResolveContext { item: Some(rows.get(index).unwrap_or(&null)), ..*ctx };
681 bind_tree(slot, &row_ctx)
682}
683
684pub fn bind_section_header(slot: &UiNode, section: &crate::list::ListSection, index: usize, ctx: &ResolveContext) -> Option<UiNode> {
688 let item = serde_json::json!({"value": section.value, "count": section.count, "index": index});
689 let section_ctx = ResolveContext { item: Some(&item), ..*ctx };
690 bind_tree(slot, §ion_ctx)
691}
692
693#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
695pub struct ActionEvent {
696 pub name: String,
697 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub context: Option<Value>,
699}
700
701#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
703pub struct ActionCall {
704 pub call: String,
705 pub args: Map<String, Value>,
706}
707
708#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
710pub struct ActionOutcome {
711 pub data: Value,
713 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub event: Option<ActionEvent>,
715 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub call: Option<ActionCall>,
717 #[serde(skip)]
720 pub written: Option<(String, Value)>,
721 #[serde(default, skip_serializing_if = "Option::is_none")]
723 pub error: Option<String>,
724}
725
726pub fn run_action(action: &Value, ctx: &ResolveContext) -> ActionOutcome {
730 let mut out = ActionOutcome { data: ctx.data.clone(), event: None, call: None, written: None, error: None };
731 let event = action.get("event").filter(|e| e.is_object()).map(|e| ActionEvent {
732 name: e.get("name").and_then(Value::as_str).unwrap_or("").to_string(),
733 context: e.get("context").filter(|c| !c.is_null()).map(|c| resolve_value(c, ctx).unwrap_or(Value::Null)),
734 });
735 if let Some(function) = action.get("functionCall").filter(|f| f.is_object()) {
737 let name = function.get("call").and_then(Value::as_str).unwrap_or("").to_string();
738 let args = match resolve_value(function.get("args").unwrap_or(&Value::Object(Map::new())), ctx) {
739 Some(Value::Object(a)) => a,
740 _ => Map::new(),
741 };
742 match (name.as_str(), args.get("path").and_then(Value::as_str)) {
743 ("set", Some(path)) if !path.starts_with('/') && ctx.item.is_some() => {}
745 ("set", None) => {}
746 ("set", Some(path)) => {
747 let pointer = absolute_path(path, ctx.scope);
748 let value = args.get("value").cloned();
749 let written = set_pointer(&mut out.data, &pointer, Some(value.clone().unwrap_or(Value::Null))).and_then(|()| match value {
752 None => set_pointer(&mut out.data, &pointer, None),
753 Some(_) => Ok(()),
754 });
755 match written {
756 Ok(()) => out.written = Some((pointer, value.unwrap_or(Value::Null))),
757 Err(e) => out.error = Some(e),
758 }
759 }
760 _ => out.call = Some(ActionCall { call: name, args }),
761 }
762 }
763 out.event = event;
764 out
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770 use serde_json::json;
771
772 #[test]
773 fn pointers_get_and_set() {
774 let mut data = json!({"user": {"name": "Ada"}, "items": [1, 2]});
775 assert_eq!(get_pointer(&data, "/user/name"), Some(&json!("Ada")));
776 assert_eq!(get_pointer(&data, "/items/1"), Some(&json!(2)));
777 set_pointer(&mut data, "/user/age", Some(json!(36))).unwrap();
778 set_pointer(&mut data, "/items/-", Some(json!(3))).unwrap();
779 set_pointer(&mut data, "/items/0", None).unwrap();
780 assert_eq!(data, json!({"user": {"name": "Ada", "age": 36}, "items": [2, 3]}));
781 set_pointer(&mut data, "/new/deep/key", Some(json!(true))).unwrap();
782 assert_eq!(get_pointer(&data, "/new/deep/key"), Some(&json!(true)));
783 assert_eq!(absolute_path("name", "/items/3"), "/items/3/name");
784 assert_eq!(absolute_path("", "/items/3"), "/items/3");
785 assert_eq!(absolute_path("/x", "/items/3"), "/x");
786 assert_eq!(absolute_path("x", ""), "/x");
787 assert_eq!(absolute_path("", ""), "");
788 let mut fresh = json!({});
789 set_pointer(&mut fresh, "/a/0/b", Some(json!(1))).unwrap();
790 assert_eq!(fresh, json!({"a": {"0": {"b": 1}}}), "missing containers are objects (writePointer)");
791 }
792
793 #[test]
794 fn functions_resolve() {
795 let data = json!({"n": 3, "email": "a@b.co", "price": 1234.5});
796 let ctx = ResolveContext::new(&data, "");
797 let call = |name: &str, args: Value| resolve_value(&json!({"call": name, "args": args}), &ctx);
798 assert_eq!(call("required", json!({"value": {"path": "/email"}})), Some(json!(true)));
799 assert_eq!(call("email", json!({"value": {"path": "/email"}})), Some(json!(true)));
800 assert_eq!(call("regex", json!({"value": "ab12", "pattern": "^[a-z]+\\d{2}$"})), Some(json!(true)));
801 assert_eq!(call("regex", json!({"value": "ab1", "pattern": "^[a-z]+\\d{2}$"})), Some(json!(false)));
802 assert_eq!(call("formatNumber", json!({"value": {"path": "/price"}})), Some(json!("1,234.5")));
803 assert_eq!(call("formatPercent", json!({"value": 0.256})), Some(json!("26%")));
804 assert_eq!(call("formatNumber", json!({"value": 1234.5, "decimals": 2})), Some(json!("1,234.50")));
805 assert_eq!(call("formatCurrency", json!({"value": 1234.5, "currency": "USD"})), Some(json!("$1,234.50")));
806 assert_eq!(call("formatString", json!({"value": "n = ${/n}"})), Some(json!("n = 3")));
807 assert_eq!(call("pluralize", json!({"value": {"path": "/n"}, "one": "item", "other": "items"})), Some(json!("items")));
808 assert_eq!(call("pluralize", json!({"value": 1, "other": "items"})), Some(json!("items")));
809 assert_eq!(call("pluralize", json!({"value": "x"})), None);
810 assert_eq!(call("formatDate", json!({"value": "2026-10-07", "format": "d MMM yyyy"})), Some(json!("7 Oct 2026")));
811 assert_eq!(call("formatDate", json!({"value": "2026-10-07", "format": "EEE"})), Some(json!("Wed")));
812 assert_eq!(call("and", json!({"values": [true, 1]})), Some(json!(true)));
813 assert_eq!(call("not", json!({"value": ""})), Some(json!(true)));
814 assert_eq!(call("length", json!({"value": "abc", "min": 2, "max": 3})), Some(json!(true)));
815 assert_eq!(call("numeric", json!({"value": "12", "min": 10})), Some(json!(true)));
816 }
817
818 #[test]
819 fn a_section_header_binds_its_section() {
820 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();
821 let data = json!({"title": "Inbox"});
822 let section = crate::list::ListSection { value: "Today".into(), start: 0, count: 2 };
823 let bound = bind_section_header(&header, §ion, 0, &ResolveContext::new(&data, "")).unwrap();
824 assert_eq!(bound.props["text"], json!("Today · 2 · Inbox"));
825 }
826
827 #[test]
828 fn a_bound_submenu_resolves_to_the_rows_of_its_source() {
829 let view = CatalogView::core();
832 let def = view.components.get("Menu").expect("Menu");
833 let props = json!({"items": [
834 {"label": "Rename", "value": "rename"},
835 {"kind": "submenu", "label": "Status", "items": {"path": "/statuses"}},
836 ]});
837 let props = props.as_object().unwrap();
838 assert_eq!(crate::validate::validate_props(def, props, "props", &view), vec![]);
839 let data = json!({"statuses": [{"label": "Backlog", "value": "backlog"}, {"label": "Done", "value": "done", "icon": "ui-check"}]});
840 let out = resolve_node_props(Some(def), props, &ResolveContext::new(&data, ""), &view.defs);
841 assert_eq!(out["items"][0], json!({"label": "Rename", "value": "rename"}));
842 assert_eq!(out["items"][1]["items"], data["statuses"]);
843 let empty = resolve_node_props(Some(def), props, &ResolveContext::new(&json!({}), ""), &view.defs);
845 assert!(empty["items"][1].get("items").is_none());
846 }
847
848 #[test]
849 fn template_scope_resolves_relative_paths() {
850 let data = json!({"items": [{"name": "one"}, {"name": "two"}]});
851 let ctx = ResolveContext::new(&data, "/items/1");
852 assert_eq!(resolve_value(&json!({"path": "name"}), &ctx), Some(json!("two")));
853 let mut paths = Vec::new();
854 binding_paths(&json!({"a": {"path": "name"}, "b": [{"path": "/x"}]}), "/items/1", &mut paths);
855 assert_eq!(paths, vec!["/items/1/name", "/x"]);
856 }
857}