Skip to main content

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}