1use std::collections::BTreeMap;
57
58use jsonptr::Pointer;
59use serde_json::{Map, Value};
60
61use crate::error::{Error, Result};
62
63pub const MAX_VALUE_DEPTH: usize = 256;
69
70pub(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
86pub(crate) fn pointer_is_valid(path: &str) -> bool {
92 Pointer::parse(path).is_ok()
93}
94
95fn encode_token(token: &str) -> String {
97 token.replace('~', "~0").replace('/', "~1")
98}
99
100pub 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 Some(other) => serde_json::to_string(other).unwrap_or_default(),
111 }
112}
113
114pub trait FunctionResolver {
121 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#[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 pub fn root(data: &'a Value) -> Self {
161 Self {
162 data,
163 base: String::new(),
164 index: None,
165 }
166 }
167
168 pub fn data(&self) -> &'a Value {
170 self.data
171 }
172
173 pub fn base(&self) -> &str {
175 &self.base
176 }
177
178 pub fn index(&self) -> Option<usize> {
180 self.index
181 }
182
183 #[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 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 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 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 pub fn resolve_string(&self, path: &str) -> String {
229 stringify(self.resolve(path))
230 }
231
232 pub fn resolve_dynamic(&self, value: &Value) -> Result<Value> {
243 self.resolve_dynamic_with(value, &NoFunctions)
244 }
245
246 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 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 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 index
345 .checked_add(offset)
346 .ok_or_else(|| Error::binding("@index", "offset is out of range"))
347 }
348
349 pub fn format_string(&self, template: &str) -> Result<String> {
361 self.format_string_with(template, &NoFunctions)
362 }
363
364 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 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 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
453fn 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" => 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 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
529fn 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
601pub 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#[derive(Debug, Clone, PartialEq, Eq)]
617pub struct Binding {
618 pub location: String,
621 pub path: String,
623 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 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 let error = scope
776 .format_string("${@index(offset: 9223372036854775807)}")
777 .unwrap_err();
778 assert!(matches!(error, Error::Binding { .. }), "{error}");
779
780 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 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 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 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 assert!(scope.resolve_dynamic(&nested(MAX_VALUE_DEPTH)).is_ok());
902 assert!(scope.resolve_dynamic(&nested(MAX_VALUE_DEPTH + 1)).is_err());
903
904 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 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}