Skip to main content

eui_core/
data.rs

1//! The surface DATA MODEL and A2UI's dynamic values: a prop may be a `{path}`
2//! binding (a JSON Pointer into the model, relative inside a template item)
3//! or a `{call, args}` client function; `resolve_value` turns a prop tree
4//! into literals for one pass. Round 1 (docs/round-1-contract.md §1): the
5//! BIND pass of `src/dynamic.ts` — [`bind_tree`] (props resolved along their
6//! schema with DATA props verbatim, styles, recipe props and `accessibility`
7//! resolved at any depth, falsy `visible` dropped, `$string.<id>` through the
8//! surface's table, row-scoped slots left for [`bind_row_slot`]) and
9//! [`run_action`] (evaluate, then `set`, then the event);
10//! `fixtures/bind-time.json` locks both. The function table
11//! = the core functions (`expr::core_function`) + the basic catalog's
12//! `and`/`or`/`not`/`required` with the core truthiness (0 is TRUE) + the
13//! basic validators and formatters.
14
15use 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
32/// A pointer's tokens; `` and `/` are the whole model.
33pub 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
40/// `path` made absolute against a template scope (`/items/3`): `/a/b`
41/// stays, `b/c` joins the scope, `` (empty) is the scope itself.
42pub 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    // VAPP-132: [`pointer_tokens`] without its allocations (a read per
57    // bound item); a token is unescaped only when it holds a `~`.
58    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
71/// The element a token READS in an array of `len` items: digits only (the
72/// tokens [`array_index`] writes; never `+1`), and `-` = the LAST element,
73/// the one an append (`-`) just landed on: a landed write reads back
74/// (EXP-1385, `fixtures/pointer.json`; RFC 6901 leaves `-` to the application).
75fn 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
85/// A pointer's tokens for a WRITE (`` and `/` = the whole model), or the
86/// limit it breaks (`maxPointerBytes`, `maxPointerSegments`).
87pub 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
101/// The index a token names in an array of `len` items: digits up to `len`
102/// (`len` and `-` append), else the refusal (JSON Pointer: never a gap,
103/// never a key). `src/dynamic.ts arrayIndex`.
104fn 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
117/// Set (or remove with `None`) the value at `pointer`, creating OBJECTS on
118/// the way (the TS `writePointer`); an array takes an index up to its
119/// length (`length` and `-` append). VAPP-103: refused (data unchanged):
120/// a pointer past `maxPointerBytes` / `maxPointerSegments`, a non-index
121/// token or an index past the end of an array. Iterative: any depth.
122pub 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    // Check first: a refusal may only come from an EXISTING array, and the
129    // walk below creates containers (fresh ones are objects).
130    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/// What `resolve_value` needs.
188#[derive(Clone, Copy)]
189pub struct ResolveContext<'a> {
190    pub data: &'a Value,
191    /// The template item's pointer, for relative paths.
192    pub scope: &'a str,
193    /// The surface's built-in string table: a `$string.<id>` value resolves
194    /// through it (`None` = left as written).
195    pub strings: Option<&'a StringTable>,
196    /// A value standing in for a pointer prefix (a literal Table row is the
197    /// data scope of its slot cells): `(prefix, value)`.
198    pub overlay: Option<(&'a str, &'a Value)>,
199    /// A LITERAL item that is not in the data model (a Table row of literal
200    /// `rows`, the TS `DataScope.item`): relative paths read inside it,
201    /// absolute ones still the data, and a relative `set` writes nothing.
202    pub item: Option<&'a Value>,
203    /// The catalog whose prop schemas decide which props are DATA
204    /// ([`bind_tree`]); `None` = the core catalog.
205    pub view: Option<&'a CatalogView>,
206    /// Round 2: the surface's formatter (`SurfaceSettings.locale` + the
207    /// host's zone); the format functions run through it. `None` = the
208    /// English fallback ([`crate::format::ENGLISH`]).
209    pub formatter: Option<&'a dyn Formatter>,
210    /// The clock (epoch ms) `formatRelativeTime` reads without a `now`
211    /// argument; `None` = the wall clock.
212    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    /// Format through the surface's formatter (see [`ResolveContext::formatter`]).
221    pub fn with_formatter(self, formatter: &'a dyn Formatter) -> ResolveContext<'a> {
222        ResolveContext { formatter: Some(formatter), ..self }
223    }
224
225    /// A fixed clock for `formatRelativeTime` (fixtures, a host re-binding
226    /// once a minute).
227    pub fn with_now(self, now_ms: f64) -> ResolveContext<'a> {
228        ResolveContext { now: Some(now_ms), ..self }
229    }
230
231    /// The formatter in effect.
232    pub fn formatter(&self) -> &'a dyn Formatter {
233        self.formatter.unwrap_or(&crate::format::ENGLISH)
234    }
235
236    /// Relative paths read inside `item` (see [`ResolveContext::item`]).
237    pub fn with_item(self, item: &'a Value) -> ResolveContext<'a> {
238        ResolveContext { item: Some(item), ..self }
239    }
240
241    /// Bind against an extended catalog (its components' prop schemas).
242    pub fn with_view(self, view: &'a CatalogView) -> ResolveContext<'a> {
243        ResolveContext { view: Some(view), ..self }
244    }
245
246    /// The value a binding path names: inside the literal item for a
247    /// relative path under an item scope, else at its absolute pointer.
248    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    /// The value at an absolute pointer (through the overlay when it names
266    /// the overlay's prefix).
267    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
309/// The function table: the six format functions through the context's
310/// formatter (round 2), the core functions, then the client functions of
311/// the basic catalog (`and`/`or`/`not`/`required` with the core
312/// truthiness, `src/dynamic.ts LOGIC_FUNCTIONS`). `openUrl` returns the url
313/// as a string the surface turns into an event for the host. `None` =
314/// unknown function or an undefined result.
315pub 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    // VAPP-191: the expression functions, on the surface clock and in the
320    // surface (team) zone (the formatter's offset).
321    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
362/// A tiny regex subset for `regex` checks: literals, `.`, `*`, `+`, `?`,
363/// `^`/`$` anchors, character classes `[...]` (ranges, negation), `\d \w \s`
364/// and alternation-free groups are NOT supported (returns false). Hosts with
365/// a real engine override through their painter; the core keeps the surface
366/// deterministic without a regex dependency.
367fn 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
513/// A prop or style value with its bindings and calls resolved at ANY depth
514/// (`resolveDynamic`). Plain objects recurse (a check's `condition`, a menu
515/// item's label); `$string.<id>` strings resolve through the context's
516/// table. `None` = undefined.
517pub 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        // A var bound to nothing (a row's missing optional field) reads null
532        // in the expression, never an unknown name (spec §4.2, expressions.json).
533        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        // VAPP-195: a binding never throws: a failing (or unknown) function
541        // shows the call's `default` (absent = no value).
542        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
560/// Every binding path a prop tree reads (absolute), for dependency tracking.
561pub 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
573// ---------------------------------------------------------------------------
574// The bind pass (round 1, `src/dynamic.ts`)
575// ---------------------------------------------------------------------------
576
577/// A node's `visible`: absent = shown; else its resolved truthiness.
578pub 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
585/// A DATA schema: a shape-less `object` (any object) or an array of them —
586/// Table `rows` in the core catalog. A literal value there is the author's
587/// data, copied verbatim: no binding, call or `$string.<id>` inside it is
588/// interpreted. A dynamic value AT the position still resolves (and its
589/// result is never descended). `src/dynamic.ts isDataSchema`.
590pub 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
598/// One prop value resolved along its schema: dynamic → resolved, DATA
599/// literal → verbatim, arrays and shaped objects → per item / property,
600/// anything else → [`resolve_value`] (any depth).
601pub 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
628/// A node's props resolved along its component's schema (an unknown
629/// component's props resolve at any depth).
630pub 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
640/// The BIND pass a renderer runs over an expanded tree for one data model:
641/// every prop resolved along its schema ([`resolve_node_props`]: DATA props
642/// verbatim), every style value, recipe prop and `accessibility` value
643/// resolved, a node whose `visible` resolves falsy dropped with its subtree,
644/// `visible` itself removed. Actions stay unresolved (they evaluate at press
645/// time, [`run_action`]); a `template` and the slots of a ROW-SCOPED
646/// component (Table cells) stay UNBOUND — the painter binds them per item /
647/// per row ([`bind_row_slot`]). Pure; `fixtures/bind-time.json` locks it.
648pub 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
688/// A row-scoped slot cell bound for row `index` (`src/dynamic.ts
689/// bindRowSlot` + `rowScope`). `rows_prop` = the UNBOUND `rows` value: a
690/// binding → relative paths read the row at `<its pointer>/<index>` in the
691/// data model (and a `set` writes into it); a literal or a call → relative
692/// paths read inside `rows[index]` (a relative `set` writes nothing).
693/// `index` = the row's index in `rows` as given, before any local sort.
694pub 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
705/// Round 2 (§4): a List's `section` header bound for section `index`
706/// (`src/dynamic.ts bindSectionHeader` + `sectionScope`): relative paths
707/// read the LITERAL item `{value, count, index}`, absolute ones the data.
708pub 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, &section_ctx)
712}
713
714/// An event an action dispatches, its context resolved BEFORE any write.
715#[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/// A function other than `set` the host must run (`openUrl`…), args resolved.
723#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
724pub struct ActionCall {
725    pub call: String,
726    pub args: Map<String, Value>,
727}
728
729/// What a press does (`src/dynamic.ts ActionOutcome`).
730#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
731pub struct ActionOutcome {
732    /// The data model after the action's `set` (unchanged without one).
733    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    /// The absolute pointer `set` wrote (not part of the TS outcome; the
739    /// surface reports it as `DataChanged`).
740    #[serde(skip)]
741    pub written: Option<(String, Value)>,
742    /// VAPP-103: the `set` was refused ([`set_pointer`]'s reason).
743    #[serde(default, skip_serializing_if = "Option::is_none")]
744    pub error: Option<String>,
745}
746
747/// What a press does: resolve the function args and the event context
748/// against the data AS IT IS, then apply `set` (relative paths against the
749/// scope, missing objects created), then hand back the event.
750pub 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    // A2UI's `functionCall`: the only action key.
757    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            // A relative path under a literal-item scope names no data: no write.
765            ("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                // No value: the key exists without one, which JSON drops
771                // (the containers on the way are still created).
772                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, &section, 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        // Round 3: `menuItem.items` is bindable; a submenu's rows may come
851        // from a host source (`bindDataModel` → statuses, members, labels).
852        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        // An unset source resolves to nothing: the submenu has no rows.
865        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}