Skip to main content

ag_ui_a2ui/
binding.rs

1//! Data-model binding: JSON Pointer resolution, scopes, and `formatString`.
2//!
3//! A2UI keeps UI structure and application state apart. Components reference
4//! state through [JSON Pointers][rfc6901], resolved against the surface's data
5//! model at render time.
6//!
7//! # Absolute and relative paths
8//!
9//! A2UI deliberately extends RFC 6901 with *relative* paths to make template
10//! iteration expressible:
11//!
12//! - A path starting with `/` is **absolute** and always resolves from the root
13//!   of the data model, wherever the component sits in the tree.
14//! - A path **not** starting with `/` is **relative** and resolves inside the
15//!   current collection scope. A container whose `children` is a template over
16//!   `/employees` opens one scope per element, so `name` inside the template
17//!   resolves to `/employees/0/name`, `/employees/1/name`, and so on.
18//!
19//! [`Scope`] models exactly this: [`Scope::root`] for the default scope and
20//! [`Scope::item`] to descend into one element of a bound collection.
21//!
22//! # Stringification
23//!
24//! Whenever a non-string is interpolated into text, the conversion is fixed by
25//! spec so every renderer agrees: numbers and booleans use their standard
26//! representation, `null` and missing paths become `""`, and objects and arrays
27//! are stringified as compact JSON.
28//!
29//! ```
30//! use ag_ui_a2ui::binding::Scope;
31//! use serde_json::json;
32//!
33//! let data = json!({"company": "Acme", "employees": [{"name": "Alice"}, {"name": "Bob"}]});
34//! let root = Scope::root(&data);
35//! let item = root.item("/employees", 1);
36//!
37//! // Relative inside the collection scope, absolute escapes back to the root.
38//! assert_eq!(item.format_string("${name} @ ${/company} (#${@index(offset: 1)})").unwrap(),
39//!            "Bob @ Acme (#2)");
40//! ```
41//!
42//! # Depth
43//!
44//! Resolution walks a `serde_json::Value`, and a walk over a tree is a stack
45//! frame per level unless something stops it. Two things do. Anything arriving
46//! over the wire has already passed through `serde_json`, which refuses to
47//! parse deeper than 127 levels, so a `Value` from a model or a transport is
48//! bounded before this module sees it. On top of that,
49//! [`Scope::resolve_dynamic`] and [`collect_bindings`] carry their own cap
50//! ([`MAX_VALUE_DEPTH`]) for values built in memory, which `serde_json` does not
51//! check. The component *graph* — where depth is genuinely unbounded — is walked
52//! iteratively in [`crate::validate`] instead.
53//!
54//! [rfc6901]: https://datatracker.ietf.org/doc/html/rfc6901
55
56use std::collections::BTreeMap;
57
58use jsonptr::Pointer;
59use serde_json::{Map, Value};
60
61use crate::error::{Error, Result};
62
63/// Deepest nesting these walks will descend into a value.
64///
65/// Well past `serde_json`'s own parse limit of 127, so it never fires on
66/// anything that arrived as text; it exists for values assembled in memory,
67/// which nothing else bounds.
68pub const MAX_VALUE_DEPTH: usize = 256;
69
70/// Splits a JSON Pointer into its decoded tokens.
71///
72/// Uses `jsonptr` for the RFC 6901 escape rules (`~1` → `/`, `~0` → `~`) and
73/// falls back to a best-effort split when the pointer contains an invalid
74/// escape, so callers reporting on bad input still get a usable locator.
75pub(crate) fn pointer_tokens(path: &str) -> Vec<String> {
76    if let Ok(pointer) = Pointer::parse(path) {
77        return pointer.tokens().map(|t| t.decoded().into_owned()).collect();
78    }
79    path.strip_prefix('/')
80        .unwrap_or(path)
81        .split('/')
82        .map(|token| token.replace("~1", "/").replace("~0", "~"))
83        .collect()
84}
85
86/// Whether a string parses as an RFC 6901 JSON Pointer.
87///
88/// The rule that catches real mistakes is the escape alphabet: `~` may only be
89/// followed by `0` or `1`, so a path written with a raw `~` in a key is invalid
90/// rather than merely absent.
91pub(crate) fn pointer_is_valid(path: &str) -> bool {
92    Pointer::parse(path).is_ok()
93}
94
95/// Encodes one path segment for use inside a JSON Pointer.
96fn encode_token(token: &str) -> String {
97    token.replace('~', "~0").replace('/', "~1")
98}
99
100/// Renders a value as a string using A2UI's fixed conversion rules.
101///
102/// `None` (an unresolved path) and `Value::Null` both become `""`.
103pub fn stringify(value: Option<&Value>) -> String {
104    match value {
105        None | Some(Value::Null) => String::new(),
106        Some(Value::String(s)) => s.clone(),
107        Some(Value::Bool(b)) => b.to_string(),
108        Some(Value::Number(n)) => n.to_string(),
109        // Objects and arrays are compact JSON so every renderer agrees.
110        Some(other) => serde_json::to_string(other).unwrap_or_default(),
111    }
112}
113
114/// Supplies renderer-side functions to `formatString` beyond the built-ins.
115///
116/// This crate does not render, so it evaluates only what it can decide on its
117/// own: data-model paths and `@index`. Pass a resolver to
118/// [`Scope::format_string_with`] to evaluate catalog functions such as
119/// `formatDate` or `formatCurrency`.
120pub trait FunctionResolver {
121    /// Evaluates `name` with the given already-resolved arguments.
122    ///
123    /// Return `None` for a function this resolver does not know; the caller then
124    /// reports it as an unknown function.
125    fn call(&self, name: &str, args: &Map<String, Value>) -> Option<Value>;
126}
127
128impl<F> FunctionResolver for F
129where
130    F: Fn(&str, &Map<String, Value>) -> Option<Value>,
131{
132    fn call(&self, name: &str, args: &Map<String, Value>) -> Option<Value> {
133        self(name, args)
134    }
135}
136
137struct NoFunctions;
138
139impl FunctionResolver for NoFunctions {
140    fn call(&self, _name: &str, _args: &Map<String, Value>) -> Option<Value> {
141        None
142    }
143}
144
145/// An evaluation scope: a data model plus the collection context bindings
146/// resolve against.
147///
148/// The root scope resolves relative paths from `/`. Entering a template item
149/// with [`Scope::item`] pushes the item's pointer as the new base and records
150/// the iteration index for [`@index`](Scope::index).
151#[derive(Debug, Clone)]
152pub struct Scope<'a> {
153    data: &'a Value,
154    base: String,
155    index: Option<usize>,
156}
157
158impl<'a> Scope<'a> {
159    /// The root scope of a data model.
160    pub fn root(data: &'a Value) -> Self {
161        Self {
162            data,
163            base: String::new(),
164            index: None,
165        }
166    }
167
168    /// The data model this scope reads from.
169    pub fn data(&self) -> &'a Value {
170        self.data
171    }
172
173    /// The absolute pointer this scope resolves relative paths against.
174    pub fn base(&self) -> &str {
175        &self.base
176    }
177
178    /// The iteration index, when this scope is a collection item.
179    pub fn index(&self) -> Option<usize> {
180        self.index
181    }
182
183    /// Descends into element `index` of the collection at `collection_path`.
184    ///
185    /// `collection_path` is itself resolved through this scope, so nested
186    /// templates compose.
187    #[must_use]
188    pub fn item(&self, collection_path: &str, index: usize) -> Scope<'a> {
189        let base = self.resolve_pointer(collection_path);
190        Scope {
191            data: self.data,
192            base: format!("{base}/{index}"),
193            index: Some(index),
194        }
195    }
196
197    /// Turns an A2UI path (absolute or relative) into an absolute JSON Pointer.
198    ///
199    /// The empty path and `/` both denote the whole data model.
200    pub fn resolve_pointer(&self, path: &str) -> String {
201        if path.is_empty() || path == "/" {
202            return String::new();
203        }
204        if let Some(rest) = path.strip_prefix('/') {
205            // Already absolute; keep the caller's escaping verbatim.
206            return format!("/{rest}");
207        }
208        let mut out = self.base.clone();
209        for segment in path.split('/') {
210            out.push('/');
211            out.push_str(&encode_token(segment));
212        }
213        out
214    }
215
216    /// Resolves a path to a value in the data model, or `None` if absent.
217    pub fn resolve(&self, path: &str) -> Option<&'a Value> {
218        let pointer = self.resolve_pointer(path);
219        if pointer.is_empty() {
220            return Some(self.data);
221        }
222        Pointer::parse(&pointer)
223            .ok()
224            .and_then(|p| p.resolve(self.data).ok())
225    }
226
227    /// Resolves a path and stringifies the result.
228    pub fn resolve_string(&self, path: &str) -> String {
229        stringify(self.resolve(path))
230    }
231
232    /// Resolves a `DynamicValue`: a literal, a `{"path": ...}` binding, or a
233    /// `{"call": ..., "args": ...}` function call.
234    ///
235    /// Objects and arrays are walked recursively so nested bindings inside an
236    /// action `context` resolve too.
237    ///
238    /// # Errors
239    ///
240    /// Returns [`Error::Binding`] if a `formatString` template is malformed or
241    /// names a function this crate cannot evaluate.
242    pub fn resolve_dynamic(&self, value: &Value) -> Result<Value> {
243        self.resolve_dynamic_with(value, &NoFunctions)
244    }
245
246    /// [`Scope::resolve_dynamic`] with catalog functions supplied by the caller.
247    ///
248    /// # Errors
249    ///
250    /// See [`Scope::resolve_dynamic`].
251    pub fn resolve_dynamic_with(
252        &self,
253        value: &Value,
254        functions: &dyn FunctionResolver,
255    ) -> Result<Value> {
256        self.resolve_dynamic_at(value, functions, 0)
257    }
258
259    fn resolve_dynamic_at(
260        &self,
261        value: &Value,
262        functions: &dyn FunctionResolver,
263        depth: usize,
264    ) -> Result<Value> {
265        if depth > MAX_VALUE_DEPTH {
266            return Err(Error::binding(
267                "<value>",
268                format!("value nests deeper than {MAX_VALUE_DEPTH} levels"),
269            ));
270        }
271        match value {
272            Value::Object(map) => {
273                if let Some(Value::String(path)) = map.get("path") {
274                    // `{componentId, path}` is a child template, not a binding.
275                    if !map.contains_key("componentId") {
276                        return Ok(self.resolve(path).cloned().unwrap_or(Value::Null));
277                    }
278                }
279                if let Some(Value::String(name)) = map.get("call") {
280                    let mut args = Map::new();
281                    if let Some(Value::Object(raw)) = map.get("args") {
282                        for (key, raw_value) in raw {
283                            args.insert(
284                                key.clone(),
285                                self.resolve_dynamic_at(raw_value, functions, depth + 1)?,
286                            );
287                        }
288                    }
289                    return self.call_function(name, &args, functions);
290                }
291                let mut out = Map::new();
292                for (key, raw_value) in map {
293                    out.insert(
294                        key.clone(),
295                        self.resolve_dynamic_at(raw_value, functions, depth + 1)?,
296                    );
297                }
298                Ok(Value::Object(out))
299            }
300            Value::Array(items) => items
301                .iter()
302                .map(|item| self.resolve_dynamic_at(item, functions, depth + 1))
303                .collect::<Result<Vec<_>>>()
304                .map(Value::Array),
305            other => Ok(other.clone()),
306        }
307    }
308
309    fn call_function(
310        &self,
311        name: &str,
312        args: &Map<String, Value>,
313        functions: &dyn FunctionResolver,
314    ) -> Result<Value> {
315        match name {
316            "formatString" => {
317                let template = stringify(args.get("value"));
318                self.format_string_with(&template, functions)
319                    .map(Value::String)
320            }
321            "@index" => self.eval_index(args).map(Value::from),
322            _ => functions.call(name, args).ok_or_else(|| {
323                Error::binding(name, "function is not evaluable outside a renderer")
324            }),
325        }
326    }
327
328    fn eval_index(&self, args: &Map<String, Value>) -> Result<i64> {
329        // Per spec, `@index` is only meaningful inside a collection scope.
330        let index = self
331            .index
332            .ok_or_else(|| Error::binding("@index", "used outside of a template iteration scope"))?
333            as i64;
334        let offset = match args.get("offset") {
335            None | Some(Value::Null) => 0,
336            Some(Value::Number(n)) => n
337                .as_i64()
338                .ok_or_else(|| Error::binding("@index", "offset must be an integer"))?,
339            Some(_) => return Err(Error::binding("@index", "offset must be a number")),
340        };
341        // The template comes from the model, so the offset is remote input.
342        // A wrapping add would hand the renderer a nonsense index; a plain one
343        // would abort the process on a debug build.
344        index
345            .checked_add(offset)
346            .ok_or_else(|| Error::binding("@index", "offset is out of range"))
347    }
348
349    /// Interpolates a `formatString` template against this scope.
350    ///
351    /// Expressions are written `${...}` and may contain an absolute path
352    /// (`${/user/name}`), a relative path (`${name}`), a literal, a nested
353    /// `${...}`, or a function call (`${@index(offset: 1)}`). A literal `${` is
354    /// written `\${`.
355    ///
356    /// # Errors
357    ///
358    /// Returns [`Error::Binding`] for an unterminated expression, trailing
359    /// characters after an expression, or a function this crate cannot evaluate.
360    pub fn format_string(&self, template: &str) -> Result<String> {
361        self.format_string_with(template, &NoFunctions)
362    }
363
364    /// [`Scope::format_string`] with catalog functions supplied by the caller.
365    ///
366    /// # Errors
367    ///
368    /// See [`Scope::format_string`].
369    pub fn format_string_with(
370        &self,
371        template: &str,
372        functions: &dyn FunctionResolver,
373    ) -> Result<String> {
374        let mut out = String::with_capacity(template.len());
375        let chars: Vec<char> = template.chars().collect();
376        let mut i = 0;
377        while i < chars.len() {
378            if chars[i] == '\\' && matches(&chars, i + 1, "${") {
379                out.push_str("${");
380                i += 3;
381                continue;
382            }
383            if matches(&chars, i, "${") {
384                let (body, next) = take_expression(&chars, i + 2)?;
385                let value = self.eval_expression(&body, functions, 0)?;
386                out.push_str(&stringify(Some(&value)));
387                i = next;
388                continue;
389            }
390            out.push(chars[i]);
391            i += 1;
392        }
393        Ok(out)
394    }
395
396    fn eval_expression(
397        &self,
398        expression: &str,
399        functions: &dyn FunctionResolver,
400        depth: usize,
401    ) -> Result<Value> {
402        const MAX_DEPTH: usize = 10;
403        if depth > MAX_DEPTH {
404            return Err(Error::binding(expression, "expression nesting is too deep"));
405        }
406        let expression = expression.trim();
407        if expression.is_empty() {
408            return Ok(Value::String(String::new()));
409        }
410
411        let chars: Vec<char> = expression.chars().collect();
412
413        // Nested `${...}`, used to make a binding explicit or chain calls.
414        if matches(&chars, 0, "${") {
415            let (body, next) = take_expression(&chars, 2)?;
416            if next != chars.len() {
417                return Err(Error::binding(
418                    expression,
419                    "unexpected characters after nested expression",
420                ));
421            }
422            return self.eval_expression(&body, functions, depth + 1);
423        }
424
425        if let Some(literal) = parse_literal(expression) {
426            return Ok(literal);
427        }
428
429        // `name(...)` is a function call; anything else is a data-model path.
430        match expression.find('(') {
431            Some(open) if expression.ends_with(')') => {
432                let name = expression[..open].trim();
433                let raw_args = &expression[open + 1..expression.len() - 1];
434                let mut args = Map::new();
435                for (key, raw) in split_arguments(raw_args, expression)? {
436                    args.insert(key, self.eval_expression(&raw, functions, depth + 1)?);
437                }
438                self.call_function(name, &args, functions)
439            }
440            Some(_) => Err(Error::binding(expression, "unbalanced parentheses")),
441            None => Ok(self.resolve(expression).cloned().unwrap_or(Value::Null)),
442        }
443    }
444}
445
446fn matches(chars: &[char], at: usize, needle: &str) -> bool {
447    needle
448        .chars()
449        .enumerate()
450        .all(|(offset, want)| chars.get(at + offset) == Some(&want))
451}
452
453/// Reads an expression body starting just after `${`, returning it and the
454/// index just past the closing `}`.
455///
456/// Tracks brace depth and skips over quoted strings so that
457/// `${formatDate(format:'{yyyy}')}` and nested `${...}` both survive.
458fn take_expression(chars: &[char], start: usize) -> Result<(String, usize)> {
459    let mut depth = 1usize;
460    let mut i = start;
461    while i < chars.len() {
462        let ch = chars[i];
463        match ch {
464            '{' => depth += 1,
465            '}' => {
466                depth -= 1;
467                if depth == 0 {
468                    return Ok((chars[start..i].iter().collect(), i + 1));
469                }
470            }
471            '\'' | '"' => {
472                let quote = ch;
473                i += 1;
474                while i < chars.len() {
475                    if chars[i] == '\\' {
476                        i += 1;
477                    } else if chars[i] == quote {
478                        break;
479                    }
480                    i += 1;
481                }
482            }
483            _ => {}
484        }
485        i += 1;
486    }
487    Err(Error::binding(
488        chars[start..].iter().collect::<String>(),
489        "unterminated interpolation: missing '}'",
490    ))
491}
492
493fn parse_literal(expression: &str) -> Option<Value> {
494    let bytes: Vec<char> = expression.chars().collect();
495    if bytes.len() >= 2 {
496        let first = bytes[0];
497        let last = bytes[bytes.len() - 1];
498        if (first == '\'' || first == '"') && first == last {
499            let inner: String = bytes[1..bytes.len() - 1].iter().collect();
500            return Some(Value::String(
501                inner.replace("\\'", "'").replace("\\\"", "\""),
502            ));
503        }
504    }
505    match expression {
506        "true" => return Some(Value::Bool(true)),
507        "false" => return Some(Value::Bool(false)),
508        // `null` interpolates as the empty string, matching the reference
509        // toolkits rather than resolving a path named "null".
510        "null" => return Some(Value::String(String::new())),
511        _ => {}
512    }
513    if expression
514        .chars()
515        .next()
516        .is_some_and(|c| c.is_ascii_digit() || c == '-')
517    {
518        // Integers stay integers: `@index(offset: 1)` must not become 1.0.
519        if let Ok(integer) = expression.parse::<i64>() {
520            return Some(Value::Number(integer.into()));
521        }
522        if let Ok(number) = expression.parse::<f64>() {
523            return serde_json::Number::from_f64(number).map(Value::Number);
524        }
525    }
526    None
527}
528
529/// Splits `name: value, name: value` argument lists, respecting nesting.
530fn split_arguments(raw: &str, expression: &str) -> Result<Vec<(String, String)>> {
531    let mut out = Vec::new();
532    if raw.trim().is_empty() {
533        return Ok(out);
534    }
535    let chars: Vec<char> = raw.chars().collect();
536    let mut depth = 0usize;
537    let mut start = 0usize;
538    let mut i = 0usize;
539    let mut pieces: Vec<String> = Vec::new();
540    while i < chars.len() {
541        match chars[i] {
542            '(' | '{' | '[' => depth += 1,
543            ')' | '}' | ']' => depth = depth.saturating_sub(1),
544            '\'' | '"' => {
545                let quote = chars[i];
546                i += 1;
547                while i < chars.len() {
548                    if chars[i] == '\\' {
549                        i += 1;
550                    } else if chars[i] == quote {
551                        break;
552                    }
553                    i += 1;
554                }
555            }
556            ',' if depth == 0 => {
557                pieces.push(chars[start..i].iter().collect());
558                start = i + 1;
559            }
560            _ => {}
561        }
562        i += 1;
563    }
564    pieces.push(chars[start..].iter().collect());
565
566    for piece in pieces {
567        let piece = piece.trim().to_string();
568        if piece.is_empty() {
569            continue;
570        }
571        let colon = split_top_level_colon(&piece).ok_or_else(|| {
572            Error::binding(
573                expression,
574                format!("argument {piece:?} is missing a 'name: value' separator"),
575            )
576        })?;
577        let name = piece[..colon].trim().to_string();
578        let value = piece[colon + 1..].trim().to_string();
579        out.push((name, value));
580    }
581    Ok(out)
582}
583
584fn split_top_level_colon(piece: &str) -> Option<usize> {
585    let mut depth = 0usize;
586    let mut in_quote: Option<char> = None;
587    for (index, ch) in piece.char_indices() {
588        match (in_quote, ch) {
589            (Some(quote), c) if c == quote => in_quote = None,
590            (Some(_), _) => {}
591            (None, '\'' | '"') => in_quote = Some(ch),
592            (None, '(' | '{' | '[') => depth += 1,
593            (None, ')' | '}' | ']') => depth = depth.saturating_sub(1),
594            (None, ':') if depth == 0 => return Some(index),
595            _ => {}
596        }
597    }
598    None
599}
600
601/// Every data-model path referenced anywhere inside a JSON value.
602///
603/// Walks the value looking for `{"path": "..."}` bindings, skipping
604/// `{"componentId", "path"}` child templates, whose `path` points at a
605/// collection rather than at a bound value. Paths are returned in encounter
606/// order with duplicates removed, keyed by the property path they were found
607/// at so a validator can report a precise locator.
608pub fn collect_bindings(value: &Value) -> Vec<Binding> {
609    let mut out = Vec::new();
610    let mut seen = BTreeMap::new();
611    walk_bindings(value, String::new(), 0, &mut out, &mut seen);
612    out
613}
614
615/// One data-model path reference found inside a component.
616#[derive(Debug, Clone, PartialEq, Eq)]
617pub struct Binding {
618    /// Where in the component the reference was found, e.g. `text` or
619    /// `action.event.context.email`.
620    pub location: String,
621    /// The path exactly as written on the wire.
622    pub path: String,
623    /// Whether the path is a template `path`, iterated rather than read.
624    pub is_collection: bool,
625}
626
627fn walk_bindings(
628    value: &Value,
629    location: String,
630    depth: usize,
631    out: &mut Vec<Binding>,
632    seen: &mut BTreeMap<(String, String), ()>,
633) {
634    // Stop rather than fail: a binding buried past this depth is unreachable in
635    // practice, and the validator reports the nesting itself.
636    if depth > MAX_VALUE_DEPTH {
637        return;
638    }
639    match value {
640        Value::Object(map) => {
641            if let Some(Value::String(path)) = map.get("path") {
642                let is_collection = map.contains_key("componentId");
643                let key = (location.clone(), path.clone());
644                if seen.insert(key, ()).is_none() {
645                    out.push(Binding {
646                        location: location.clone(),
647                        path: path.clone(),
648                        is_collection,
649                    });
650                }
651                if is_collection {
652                    return;
653                }
654            }
655            for (key, child) in map {
656                if key == "path" {
657                    continue;
658                }
659                let next = if location.is_empty() {
660                    key.clone()
661                } else {
662                    format!("{location}.{key}")
663                };
664                walk_bindings(child, next, depth + 1, out, seen);
665            }
666        }
667        Value::Array(items) => {
668            for (index, item) in items.iter().enumerate() {
669                walk_bindings(item, format!("{location}[{index}]"), depth + 1, out, seen);
670            }
671        }
672        _ => {}
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679    use serde_json::json;
680
681    fn model() -> Value {
682        json!({
683            "company": "Acme Corp",
684            "count": 3,
685            "flag": true,
686            "nested": {"a": [1, 2]},
687            "employees": [
688                {"name": "Alice", "role": "Engineer"},
689                {"name": "Bob", "role": "Designer"}
690            ]
691        })
692    }
693
694    #[test]
695    fn absolute_paths_resolve_from_the_root_in_any_scope() {
696        let data = model();
697        let root = Scope::root(&data);
698        let scope = root.item("/employees", 1);
699        assert_eq!(scope.resolve_string("/company"), "Acme Corp");
700        assert_eq!(root.resolve_string("/company"), "Acme Corp");
701    }
702
703    #[test]
704    fn relative_paths_resolve_inside_the_collection_scope() {
705        let data = model();
706        let root = Scope::root(&data);
707        for (index, expected) in [(0, "Alice"), (1, "Bob")] {
708            let item = root.item("/employees", index);
709            assert_eq!(item.resolve_string("name"), expected);
710            assert_eq!(item.base(), format!("/employees/{index}"));
711        }
712    }
713
714    #[test]
715    fn nested_templates_compose_scopes() {
716        let data = json!({"groups": [{"items": [{"label": "inner"}]}]});
717        let root = Scope::root(&data);
718        let group = root.item("/groups", 0);
719        let item = group.item("items", 0);
720        assert_eq!(item.base(), "/groups/0/items/0");
721        assert_eq!(item.resolve_string("label"), "inner");
722    }
723
724    #[test]
725    fn stringification_follows_the_spec_table() {
726        let data = model();
727        let scope = Scope::root(&data);
728        assert_eq!(scope.resolve_string("/count"), "3");
729        assert_eq!(scope.resolve_string("/flag"), "true");
730        assert_eq!(scope.resolve_string("/missing"), "");
731        assert_eq!(scope.resolve_string("/nested"), r#"{"a":[1,2]}"#);
732        assert_eq!(scope.resolve_string("/nested/a"), "[1,2]");
733        assert_eq!(stringify(Some(&Value::Null)), "");
734    }
735
736    #[test]
737    fn format_string_mixes_text_paths_and_escapes() {
738        let data = model();
739        let scope = Scope::root(&data);
740        assert_eq!(
741            scope
742                .format_string("Hello, ${/company}! You have ${/count} messages.")
743                .unwrap(),
744            "Hello, Acme Corp! You have 3 messages."
745        );
746        assert_eq!(
747            scope.format_string(r"literal \${/company}").unwrap(),
748            "literal ${/company}"
749        );
750        assert_eq!(
751            scope.format_string("no expressions").unwrap(),
752            "no expressions"
753        );
754        assert_eq!(scope.format_string("${/missing}").unwrap(), "");
755    }
756
757    #[test]
758    fn format_string_supports_index_with_offset_and_nesting() {
759        let data = model();
760        let root = Scope::root(&data);
761        let scope = root.item("/employees", 1);
762        assert_eq!(scope.format_string("#${@index()}").unwrap(), "#1");
763        assert_eq!(scope.format_string("#${@index(offset: 1)}").unwrap(), "#2");
764        assert_eq!(scope.format_string("${${name}}").unwrap(), "Bob");
765    }
766
767    #[test]
768    fn an_index_offset_that_would_overflow_is_reported_rather_than_wrapping() {
769        let data = model();
770        let root = Scope::root(&data);
771        let scope = root.item("/employees", 1);
772        // The template is model output, so the offset is remote input: an
773        // extreme one must not take the process down (debug) or silently wrap
774        // to a nonsense index (release).
775        let error = scope
776            .format_string("${@index(offset: 9223372036854775807)}")
777            .unwrap_err();
778        assert!(matches!(error, Error::Binding { .. }), "{error}");
779
780        // Everything that does fit still evaluates, in both directions.
781        assert_eq!(scope.format_string("${@index(offset: -1)}").unwrap(), "0");
782        assert_eq!(scope.format_string("${@index(offset: 1)}").unwrap(), "2");
783        assert_eq!(
784            scope
785                .format_string("${@index(offset: -9223372036854775808)}")
786                .unwrap(),
787            "-9223372036854775807"
788        );
789    }
790
791    #[test]
792    fn index_outside_a_collection_scope_is_an_error() {
793        let data = model();
794        let scope = Scope::root(&data);
795        let err = scope.format_string("${@index()}").unwrap_err();
796        assert!(matches!(err, Error::Binding { .. }));
797    }
798
799    #[test]
800    fn unterminated_and_unknown_expressions_are_errors() {
801        let data = model();
802        let scope = Scope::root(&data);
803        assert!(scope.format_string("${/company").is_err());
804        assert!(
805            scope
806                .format_string("${formatDate(value: '2026-01-01')}")
807                .is_err()
808        );
809    }
810
811    #[test]
812    fn caller_supplied_functions_are_used() {
813        let data = model();
814        let scope = Scope::root(&data);
815        let upper = |name: &str, args: &Map<String, Value>| -> Option<Value> {
816            (name == "upper").then(|| Value::String(stringify(args.get("value")).to_uppercase()))
817        };
818        assert_eq!(
819            scope
820                .format_string_with("${upper(value: ${/company})}", &upper)
821                .unwrap(),
822            "ACME CORP"
823        );
824    }
825
826    #[test]
827    fn resolve_dynamic_walks_bindings_and_calls() {
828        let data = model();
829        let scope = Scope::root(&data);
830        let resolved = scope
831            .resolve_dynamic(&json!({
832                "literal": "static",
833                "bound": {"path": "/company"},
834                "formatted": {"call": "formatString", "args": {"value": "n=${/count}"}}
835            }))
836            .unwrap();
837        assert_eq!(
838            resolved,
839            json!({"literal": "static", "bound": "Acme Corp", "formatted": "n=3"})
840        );
841    }
842
843    #[test]
844    fn collect_bindings_separates_templates_from_value_bindings() {
845        let component = json!({
846            "text": {"path": "/user/name"},
847            "children": {"componentId": "tpl", "path": "/items"},
848            "action": {"event": {"context": {"email": {"path": "/form/email"}}}}
849        });
850        let bindings = collect_bindings(&component);
851        let found: Vec<_> = bindings
852            .iter()
853            .map(|b| (b.location.as_str(), b.path.as_str(), b.is_collection))
854            .collect();
855        assert!(found.contains(&("text", "/user/name", false)));
856        assert!(found.contains(&("children", "/items", true)));
857        assert!(found.contains(&("action.event.context.email", "/form/email", false)));
858    }
859
860    #[test]
861    fn a_value_nested_past_the_cap_errors_instead_of_recursing_away() {
862        let data = json!({"name": "Ada"});
863        let scope = Scope::root(&data);
864
865        // Deeper than MAX_VALUE_DEPTH, but shallow enough that `Value`'s own
866        // recursive Clone and Drop still cope — past that point the type itself
867        // is the limit, not this crate.
868        let mut value = json!({"path": "/name"});
869        for _ in 0..(MAX_VALUE_DEPTH + 50) {
870            value = json!({"nested": value});
871        }
872        let error = scope.resolve_dynamic(&value).unwrap_err();
873        assert!(matches!(error, Error::Binding { .. }), "{error}");
874
875        // Just inside the cap still resolves.
876        let mut value = json!({"path": "/name"});
877        for _ in 0..10 {
878            value = json!({"nested": value});
879        }
880        assert!(scope.resolve_dynamic(&value).is_ok());
881    }
882
883    /// Nests a binding `wrappers` objects deep. The outermost object sits at
884    /// depth 0, so `wrappers` is the depth the innermost one is reached at.
885    fn nested(wrappers: usize) -> Value {
886        let mut value = json!({"path": "/name"});
887        for _ in 0..wrappers {
888            value = json!({"nested": value});
889        }
890        value
891    }
892
893    #[test]
894    fn the_value_depth_cap_fires_one_level_past_it_and_not_before() {
895        let data = json!({"name": "Ada"});
896        let scope = Scope::root(&data);
897
898        // Off-by-one here is the difference between rejecting input the spec
899        // allows and letting a deeper one through, so the boundary is pinned
900        // rather than probed from far away.
901        assert!(scope.resolve_dynamic(&nested(MAX_VALUE_DEPTH)).is_ok());
902        assert!(scope.resolve_dynamic(&nested(MAX_VALUE_DEPTH + 1)).is_err());
903
904        // `collect_bindings` shares the cap but stops rather than failing, so
905        // the binding at the bottom is simply not reported.
906        assert_eq!(collect_bindings(&nested(MAX_VALUE_DEPTH)).len(), 1);
907        assert!(collect_bindings(&nested(MAX_VALUE_DEPTH + 1)).is_empty());
908    }
909
910    #[test]
911    fn collecting_bindings_stops_at_the_cap_rather_than_recursing_away() {
912        let mut value = json!({"path": "/deep"});
913        for _ in 0..(MAX_VALUE_DEPTH + 50) {
914            value = json!({"nested": value});
915        }
916        // No panic, no overflow; the buried binding is simply not reported, and
917        // the validator flags the nesting itself.
918        assert!(collect_bindings(&value).is_empty());
919    }
920
921    #[test]
922    fn pointer_escapes_round_trip() {
923        assert_eq!(pointer_tokens("/a~1b/c~0d"), vec!["a/b", "c~d"]);
924        let data = json!({"a/b": 7});
925        assert_eq!(Scope::root(&data).resolve_string("/a~1b"), "7");
926    }
927}