polyspec_crudui_validator/compose/ref_.rs
1//! `$ref` resolution expands base inheritance before other composition (SPEC §5).
2//!
3//! Resolution supports these input forms:
4//! (1) value = a single string OR an array of strings — an array resolves each
5//! path in order, then array_merge (later overrides earlier on key clash).
6//! (2) plain path `options.yml` → load YAML, descend by the default
7//! detectKey `['properties']` (= take the file's `properties` only).
8//! (3) path-specified `(file.yml).a.b` → regex split, detectKeys = ['a','b',
9//! 'properties'] — descend a.b, then descend to `properties` underneath.
10//! (4) relative paths get the basepath '/' prefix; absolute (/-leading) pass
11//! through (handled by the FileLoader).
12//! (5) the result is recursively processed — nested `$ref` is expanded.
13//!
14//! CRUDUI normalization: `$ref` is the `properties`-layer composition entry point.
15//! The resolved result is flattened to a single properties map and laid down as
16//! the base; `$patch` overlays it (base first, patch overrides). Unresolved
17//! `$ref` (missing file / bad format / absent detectKey / cycle) is a LOAD ERROR
18//! — never `valid:true`.
19
20use std::collections::BTreeSet;
21
22use serde_json::{Map, Value};
23
24use super::errors::{ComposeErrorCode, ComposeLoadError, ComposeResult};
25use super::loader::FileLoader;
26use super::member_order::member_ordered_map;
27use super::members::{kind_of, layer_members};
28
29/// Resolve a `$ref` value (string or string[]) to a single flattened properties
30/// map. Each entry is resolved in declaration order and merged (later overrides
31/// earlier). Nested `$ref` inside a resolved doc is expanded recursively. The
32/// `visiting` set (canonical file keys on the current chain) detects cycles.
33pub fn resolve_ref(
34 value: &Value,
35 basepath: &str,
36 loader: &dyn FileLoader,
37 visiting: &BTreeSet<String>,
38) -> ComposeResult<Map<String, Value>> {
39 let paths = normalize_ref_value(value)?;
40
41 let mut merged: Map<String, Value> = Map::new();
42 for path in &paths {
43 let resolved = resolve_single_ref(path, basepath, loader, visiting)?;
44 // array_merge: later keys override earlier (declaration order).
45 for (k, v) in resolved {
46 merged.insert(k, v);
47 }
48 }
49 Ok(merged)
50}
51
52/// Normalize the `$ref` value into a list of path strings (scalar → [scalar]).
53fn normalize_ref_value(value: &Value) -> ComposeResult<Vec<String>> {
54 match value {
55 Value::String(s) => Ok(vec![s.clone()]),
56 Value::Array(items) => {
57 let mut out = Vec::with_capacity(items.len());
58 for p in items {
59 match p {
60 Value::String(s) => out.push(s.clone()),
61 other => {
62 return Err(ComposeLoadError::new(
63 ComposeErrorCode::RefValueType,
64 format!("$ref array entries must be strings, got {}", kind_of(other)),
65 ));
66 }
67 }
68 }
69 Ok(out)
70 }
71 other => Err(ComposeLoadError::new(
72 ComposeErrorCode::RefValueType,
73 format!(
74 "$ref must be a string or an array of strings, got {}",
75 kind_of(other)
76 ),
77 )),
78 }
79}
80
81/// Parse the path-specified form `(file.yml).a.b`.
82/// Returns `Some((path, keys))` on a match, `None` otherwise. Hand-rolled (no
83/// regex dependency on the hot path): leading `(`, a closing `)` followed by `.`,
84/// then the remaining key chain. Mirrors `^\((?<path>.*?)\)\.(?<keys>.*)$`.
85fn parse_path_spec(s: &str) -> Option<(String, String)> {
86 if !s.starts_with('(') {
87 return None;
88 }
89 // Non-greedy `(.*?)` then `).` — find the FIRST ")." after the opening "(".
90 let inner = &s[1..];
91 let close = inner.find(").")?;
92 let path = inner[..close].to_string();
93 let keys = inner[close + 2..].to_string();
94 Some((path, keys))
95}
96
97/// Resolve one `$ref` path entry, descending detectKeys and expanding nested refs.
98fn resolve_single_ref(
99 raw_path: &str,
100 basepath: &str,
101 loader: &dyn FileLoader,
102 visiting: &BTreeSet<String>,
103) -> ComposeResult<Map<String, Value>> {
104 let org_path = raw_path;
105 let mut path = raw_path.to_string();
106 let mut detect_keys: Vec<String> = vec!["properties".to_string()];
107
108 // Path-specified form `(file.yml).a.b` (leading '(').
109 if path.starts_with('(') {
110 match parse_path_spec(&path) {
111 Some((p, keys)) => {
112 path = p;
113 // detectKeys = explode('.', keys) ++ ['properties'].
114 detect_keys = keys
115 .split('.')
116 .map(|s| s.to_string())
117 .chain(std::iter::once("properties".to_string()))
118 .collect();
119 }
120 None => {
121 return Err(ComposeLoadError::new(
122 ComposeErrorCode::RefFormatError,
123 format!("{} ref error", org_path),
124 ));
125 }
126 }
127 }
128
129 // Empty path is a format error.
130 if path.is_empty() {
131 return Err(ComposeLoadError::new(
132 ComposeErrorCode::RefFormatError,
133 format!("{} ref error", org_path),
134 ));
135 }
136
137 let key = loader.normalize(&path, basepath);
138
139 // Cycle detection: this file key already on the current resolution chain
140 // (detected instead of recursing forever — SPEC §7).
141 if visiting.contains(&key) {
142 let mut trace: Vec<String> = visiting.iter().cloned().collect();
143 trace.push(key.clone());
144 return Err(ComposeLoadError::with_trace(
145 ComposeErrorCode::RefCycle,
146 format!("$ref cycle detected: {}", trace.join(" -> ")),
147 trace,
148 ));
149 }
150
151 // RefFileNotFound if absent. Every loaded document, custom loaders included,
152 // is a specification in member order.
153 let doc = member_ordered_map(&loader.load(&key)?);
154
155 // Descend detectKeys.
156 let mut node: Value = Value::Object(doc);
157 for detect_key in &detect_keys {
158 let next = match &node {
159 Value::Object(obj) => obj.get(detect_key).cloned(),
160 _ => None,
161 };
162 match next {
163 Some(v) => node = v,
164 None => {
165 let mut trace: Vec<String> = visiting.iter().cloned().collect();
166 trace.push(key.clone());
167 return Err(ComposeLoadError::with_trace(
168 ComposeErrorCode::RefDetectKeyNotFound,
169 format!("{} not found in {}", detect_key, org_path),
170 trace,
171 ));
172 }
173 }
174 }
175
176 // A properties layer must be a map. A scalar/array here is a malformed ref.
177 let node_obj = match node {
178 Value::Object(obj) => obj,
179 _ => {
180 let mut trace: Vec<String> = visiting.iter().cloned().collect();
181 trace.push(key.clone());
182 return Err(ComposeLoadError::with_trace(
183 ComposeErrorCode::RefDetectKeyNotFound,
184 format!("{} resolved to a non-object properties layer", org_path),
185 trace,
186 ));
187 }
188 };
189
190 // Recursively expand nested $ref inside the resolved properties map. Add this
191 // file key to the visiting chain so a deeper $ref back to it is a cycle.
192 let mut next_visiting = visiting.clone();
193 next_visiting.insert(key);
194 expand_nested_refs(node_obj, basepath, loader, &next_visiting)
195}
196
197/// Expand any `$ref` (and merge any `$patch`) sitting INSIDE a resolved
198/// properties map, recursively. The resolved base is laid down first, then sibling named keys
199/// override it (declaration order: a later plain key overrides an earlier $ref).
200fn expand_nested_refs(
201 node: Map<String, Value>,
202 basepath: &str,
203 loader: &dyn FileLoader,
204 visiting: &BTreeSet<String>,
205) -> ComposeResult<Map<String, Value>> {
206 if !node.contains_key("$ref") && !node.contains_key("$patch") {
207 return Ok(node);
208 }
209
210 // Preserve declaration order: $ref expands to the base; keys declared after it
211 // override, keys before it are overridden by it (positional merge).
212 layer_members(node, |v| resolve_ref(v, basepath, loader, visiting))
213}