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    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
352/// A tiny regex subset for `regex` checks: literals, `.`, `*`, `+`, `?`,
353/// `^`/`$` anchors, character classes `[...]` (ranges, negation), `\d \w \s`
354/// and alternation-free groups are NOT supported (returns false). Hosts with
355/// a real engine override through their painter; the core keeps the surface
356/// deterministic without a regex dependency.
357fn 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
503/// A prop or style value with its bindings and calls resolved at ANY depth
504/// (`resolveDynamic`). Plain objects recurse (a check's `condition`, a menu
505/// item's label); `$string.<id>` strings resolve through the context's
506/// table. `None` = undefined.
507pub 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
539/// Every binding path a prop tree reads (absolute), for dependency tracking.
540pub 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
552// ---------------------------------------------------------------------------
553// The bind pass (round 1, `src/dynamic.ts`)
554// ---------------------------------------------------------------------------
555
556/// A node's `visible`: absent = shown; else its resolved truthiness.
557pub 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
564/// A DATA schema: a shape-less `object` (any object) or an array of them —
565/// Table `rows` in the core catalog. A literal value there is the author's
566/// data, copied verbatim: no binding, call or `$string.<id>` inside it is
567/// interpreted. A dynamic value AT the position still resolves (and its
568/// result is never descended). `src/dynamic.ts isDataSchema`.
569pub 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
577/// One prop value resolved along its schema: dynamic → resolved, DATA
578/// literal → verbatim, arrays and shaped objects → per item / property,
579/// anything else → [`resolve_value`] (any depth).
580pub 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
607/// A node's props resolved along its component's schema (an unknown
608/// component's props resolve at any depth).
609pub 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
619/// The BIND pass a renderer runs over an expanded tree for one data model:
620/// every prop resolved along its schema ([`resolve_node_props`]: DATA props
621/// verbatim), every style value, recipe prop and `accessibility` value
622/// resolved, a node whose `visible` resolves falsy dropped with its subtree,
623/// `visible` itself removed. Actions stay unresolved (they evaluate at press
624/// time, [`run_action`]); a `template` and the slots of a ROW-SCOPED
625/// component (Table cells) stay UNBOUND — the painter binds them per item /
626/// per row ([`bind_row_slot`]). Pure; `fixtures/bind-time.json` locks it.
627pub 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
667/// A row-scoped slot cell bound for row `index` (`src/dynamic.ts
668/// bindRowSlot` + `rowScope`). `rows_prop` = the UNBOUND `rows` value: a
669/// binding → relative paths read the row at `<its pointer>/<index>` in the
670/// data model (and a `set` writes into it); a literal or a call → relative
671/// paths read inside `rows[index]` (a relative `set` writes nothing).
672/// `index` = the row's index in `rows` as given, before any local sort.
673pub 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
684/// Round 2 (§4): a List's `section` header bound for section `index`
685/// (`src/dynamic.ts bindSectionHeader` + `sectionScope`): relative paths
686/// read the LITERAL item `{value, count, index}`, absolute ones the data.
687pub 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, &section_ctx)
691}
692
693/// An event an action dispatches, its context resolved BEFORE any write.
694#[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/// A function other than `set` the host must run (`openUrl`…), args resolved.
702#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
703pub struct ActionCall {
704    pub call: String,
705    pub args: Map<String, Value>,
706}
707
708/// What a press does (`src/dynamic.ts ActionOutcome`).
709#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
710pub struct ActionOutcome {
711    /// The data model after the action's `set` (unchanged without one).
712    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    /// The absolute pointer `set` wrote (not part of the TS outcome; the
718    /// surface reports it as `DataChanged`).
719    #[serde(skip)]
720    pub written: Option<(String, Value)>,
721    /// VAPP-103: the `set` was refused ([`set_pointer`]'s reason).
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    pub error: Option<String>,
724}
725
726/// What a press does: resolve the function args and the event context
727/// against the data AS IT IS, then apply `set` (relative paths against the
728/// scope, missing objects created), then hand back the event.
729pub 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    // A2UI's `functionCall`: the only action key.
736    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            // A relative path under a literal-item scope names no data: no write.
744            ("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                // No value: the key exists without one, which JSON drops
750                // (the containers on the way are still created).
751                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, &section, 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        // Round 3: `menuItem.items` is bindable; a submenu's rows may come
830        // from a host source (`bindDataModel` → statuses, members, labels).
831        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        // An unset source resolves to nothing: the submenu has no rows.
844        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}