Skip to main content

json_eval_rs/jsoneval/
layout.rs

1use super::JSONEval;
2use crate::jsoneval::path_utils;
3use crate::jsoneval::types::{LayoutOverlayEntry, ResolvedLayoutResult};
4use crate::time_block;
5
6use indexmap::IndexMap;
7use serde_json::Value;
8
9impl JSONEval {
10    /// Resolve layout references, return overlay entries.
11    ///
12    /// Unlike old version: does NOT mutate evaluated_schema.
13    /// Returns list of overlay entries describing delta properties per element.
14    ///
15    /// # Arguments
16    ///
17    /// * `evaluate` - If true, runs evaluation before resolving layout.
18    pub fn resolve_layout(&mut self, evaluate: bool) -> Result<ResolvedLayoutResult, String> {
19        if evaluate {
20            let data_str = serde_json::to_string(&self.data)
21                .map_err(|e| format!("Failed to serialize data: {}", e))?;
22            self.evaluate(&data_str, None, None, None)?;
23        }
24
25        Ok(self.resolve_layout_internal())
26    }
27
28    fn resolve_layout_internal(&mut self) -> ResolvedLayoutResult {
29        time_block!("  resolve_layout_internal()", {
30            let layout_paths = self.layout_paths.clone();
31            let mut all_entries = ResolvedLayoutResult::new();
32
33            // Resolve every ref from current evaluated_schema. Visibility state remains
34            // ephemeral: overlays and hidden indexes never mutate evaluated_schema.
35            self.layout_hidden_refs.clear();
36            self.layout_visible_refs.clear();
37            self.layout_condition_hidden_refs.clear();
38            // A nested field layout is expanded by its parent's `$ref` tree. Resolving it
39            // again as a standalone root loses its actual parent visibility and would make a
40            // shared ref look visible when every real attachment is hidden.
41            let attached_layout_refs = Self::collect_layout_ref_targets(&self.schema);
42            time_block!("    resolve_layout_elements", {
43                for layout_path in layout_paths.iter().filter(|path| {
44                    let owner = Self::layout_owner_pointer(path);
45                    owner.is_empty() || !attached_layout_refs.contains(&owner)
46                }) {
47                    let resolved_tree = self.resolve_elements_tree(layout_path);
48                    let entries = Self::tree_to_overlays(
49                        &resolved_tree,
50                        layout_path,
51                        false,
52                        false,
53                        false,
54                        &mut self.layout_hidden_refs,
55                        &mut self.layout_visible_refs,
56                        &mut self.layout_condition_hidden_refs,
57                    );
58                    all_entries.extend(entries);
59                }
60            });
61
62            // Schema-wide filtering and clearing apply only if no attached layout occurrence
63            // renders this ref visible. Overlay entries above retain per-occurrence state.
64            for visible_ref in &self.layout_visible_refs {
65                self.layout_hidden_refs.shift_remove(visible_ref);
66                self.layout_condition_hidden_refs.shift_remove(visible_ref);
67            }
68
69            all_entries
70        })
71    }
72
73    // ── Phase 1 helpers ─────────────────────────────────────────────
74
75    /// Return schema pointer owning `.../$layout/elements`; root layouts have no owner.
76    fn layout_owner_pointer(layout_path: &str) -> String {
77        let owner = layout_path
78            .trim_end_matches("/$layout/elements")
79            .trim_start_matches('#');
80        owner.to_string()
81    }
82
83    /// Collect schema targets referenced from layout elements only. Formula `$ref`s are
84    /// intentionally ignored: they do not attach a field to a visual layout parent.
85    fn collect_layout_ref_targets(schema: &Value) -> indexmap::IndexSet<String> {
86        fn collect_elements(elements: &Value, refs: &mut indexmap::IndexSet<String>) {
87            let Some(elements) = elements.as_array() else {
88                return;
89            };
90            for element in elements {
91                let Some(map) = element.as_object() else {
92                    continue;
93                };
94                if let Some(reference) = map.get("$ref").and_then(Value::as_str) {
95                    let pointer = path_utils::normalize_to_json_pointer(
96                        &path_utils::dot_notation_to_schema_pointer(reference),
97                    )
98                    .trim_start_matches('#')
99                    .to_string();
100                    refs.insert(pointer);
101                }
102                if let Some(children) = map.get("elements") {
103                    collect_elements(children, refs);
104                }
105            }
106        }
107
108        fn walk(value: &Value, refs: &mut indexmap::IndexSet<String>) {
109            let Some(map) = value.as_object() else {
110                return;
111            };
112            if let Some(elements) = map
113                .get("$layout")
114                .and_then(Value::as_object)
115                .and_then(|layout| layout.get("elements"))
116            {
117                collect_elements(elements, refs);
118            }
119            for child in map.values() {
120                walk(child, refs);
121            }
122        }
123
124        let mut refs = indexmap::IndexSet::new();
125        walk(schema, &mut refs);
126        refs
127    }
128
129    /// Resolve $ref in elements tree, return full resolved tree (no parent cascade yet).
130    /// Returns Vec<(resolved_element, schema_ref_path)> — one per element at this level.
131    fn resolve_elements_tree(&self, layout_elements_path: &str) -> Vec<(Value, String)> {
132        let normalized_path = path_utils::normalize_to_json_pointer(layout_elements_path);
133
134        let elements = if let Some(Value::Array(arr)) = self.schema.pointer(&normalized_path) {
135            arr.clone()
136        } else {
137            return Vec::new();
138        };
139
140        let mut result = Vec::with_capacity(elements.len());
141        for element in elements.into_iter() {
142            let (resolved, schema_ref) = self.resolve_element_ref_recursive(element, "");
143            result.push((resolved, schema_ref));
144        }
145        result
146    }
147
148    /// Resolve an element's $ref recursively, returning (resolved_element, schema_ref_path).
149    /// schema_ref_path is the outermost $ref target (empty if no $ref).
150    ///
151    /// The resolved element is a "full tree" — $ref expanded, metadata injected,
152    /// nested elements arrays also resolved. Used for parent condition cascade.
153    fn resolve_element_ref_recursive(
154        &self,
155        element: Value,
156        _path_context: &str,
157    ) -> (Value, String) {
158        // Resolve this element's $ref
159        let (mut resolved, ref_path) = self.resolve_element_ref(element);
160
161        // Recursively resolve nested elements
162        if let Value::Object(ref mut map) = resolved {
163            if let Some(Value::Array(elements)) = map.get("elements").cloned() {
164                let mut resolved_nested = Vec::with_capacity(elements.len());
165                for nested_element in elements.into_iter() {
166                    let (nested_resolved, _) =
167                        self.resolve_element_ref_recursive(nested_element, "");
168                    resolved_nested.push(nested_resolved);
169                }
170                map.insert("elements".to_string(), Value::Array(resolved_nested));
171            }
172        }
173
174        (resolved, ref_path)
175    }
176
177    /// Resolve $ref in a single element. Returns (resolved_element, schema_ref_path).
178    /// Does NOT recurse into nested elements.
179    fn resolve_element_ref(&self, element: Value) -> (Value, String) {
180        match element {
181            Value::Object(mut map) => {
182                let has_ref = map.get("$ref").is_some();
183                let ref_path = if has_ref {
184                    map.get("$ref")
185                        .and_then(Value::as_str)
186                        .unwrap_or("")
187                        .to_string()
188                } else {
189                    String::new()
190                };
191
192                if let Some(Value::String(ref_str)) = map.get("$ref").cloned() {
193                    // Resolve the $ref to an actual schema pointer first
194                    let normalized_path = if ref_str.starts_with('#') || ref_str.starts_with('/') {
195                        path_utils::normalize_to_json_pointer(&ref_str).into_owned()
196                    } else {
197                        let schema_pointer = path_utils::dot_notation_to_schema_pointer(&ref_str);
198                        let schema_path =
199                            path_utils::normalize_to_json_pointer(&schema_pointer).into_owned();
200
201                        if self.evaluated_schema.pointer(&schema_path).is_some() {
202                            schema_path
203                        } else {
204                            format!("/properties/{}", ref_str.replace('.', "/properties/"))
205                        }
206                    };
207
208                    // Build $fullpath from the actual resolved pointer (not the raw $ref string).
209                    // This ensures $fullpath always reflects the true schema field path.
210                    let dotted_path = path_utils::pointer_to_dot_notation(&normalized_path);
211                    let last_segment = dotted_path.split('.').last().unwrap_or(&dotted_path);
212
213                    map.insert("$fullpath".to_string(), Value::String(dotted_path.clone()));
214                    map.insert("$path".to_string(), Value::String(last_segment.to_string()));
215                    map.insert("$parentHide".to_string(), Value::Bool(false));
216
217                    if let Some(referenced_value) = self.evaluated_schema.pointer(&normalized_path)
218                    {
219                        let resolved = referenced_value.clone();
220
221                        if let Value::Object(mut resolved_map) = resolved {
222                            map.remove("$ref");
223
224                            if let Some(Value::Object(layout_obj)) = resolved_map.remove("$layout")
225                            {
226                                let mut result = layout_obj.clone();
227                                for (key, value) in resolved_map {
228                                    if key != "type" || !result.contains_key("type") {
229                                        result.insert(key, value);
230                                    }
231                                }
232                                // Ensure $fullpath from the map (actual ref path) wins
233                                for (key, value) in map {
234                                    result.insert(key, value);
235                                }
236                                return (Value::Object(result), dotted_path);
237                            } else {
238                                for (key, value) in map {
239                                    resolved_map.insert(key, value);
240                                }
241                                return (Value::Object(resolved_map), dotted_path);
242                            }
243                        } else {
244                            return (resolved, dotted_path);
245                        }
246                    }
247                }
248
249                (Value::Object(map), ref_path)
250            }
251            _ => (element, String::new()),
252        }
253    }
254
255    /// Convert a resolved elements tree into flat overlay entries with parent condition cascade.
256    ///
257    /// Walks the tree recursively. At each level:
258    ///   1. Determine parent state (hidden/disabled propagated from above)
259    ///   2. Merge element's own condition with inherited state
260    ///   3. Emit one LayoutOverlayEntry per element with full overlay delta
261    ///
262    /// The `base_ref_path` is the parent's `schema_ref_path` (empty for root).
263    fn tree_to_overlays(
264        tree: &[(Value, String)],
265        layout_path: &str,
266        parent_hidden: bool,
267        parent_condition_hidden: bool,
268        parent_disabled: bool,
269        layout_hidden_refs: &mut indexmap::IndexSet<String>,
270        layout_visible_refs: &mut indexmap::IndexSet<String>,
271        layout_condition_hidden_refs: &mut indexmap::IndexSet<String>,
272    ) -> ResolvedLayoutResult {
273        let mut entries = ResolvedLayoutResult::new();
274
275        for (idx, (element, ref_path)) in tree.iter().enumerate() {
276            let element_idx = idx;
277            let mut overlay = IndexMap::new();
278
279            // ── Build thin overlay: only delta props (skip structural keys) ──
280            if let Value::Object(map) = element {
281                // Keys that should NOT appear in overlay:
282                // - $ref: handled by compact schema
283                // - elements: handled by child overlays
284                // - properties/items/required: schema structure, not layout
285                const EXCLUDED: &[&str] = &[
286                    "$ref",
287                    "elements",
288                    "properties",
289                    "items",
290                    "required",
291                    "additionalProperties",
292                ];
293                for (key, value) in map {
294                    if !EXCLUDED.contains(&key.as_str()) {
295                        overlay.insert(key.clone(), value.clone());
296                    }
297                }
298
299                // ── Inject $fullpath for ALL elements (ref and non-ref) ──
300                if !overlay.contains_key("$fullpath") {
301                    if !ref_path.is_empty() {
302                        // $ref element: use the actual schema ref target dotted path
303                        let last_segment = ref_path.split('.').last().unwrap_or(ref_path);
304                        overlay.insert("$fullpath".to_string(), Value::String(ref_path.clone()));
305                        overlay
306                            .insert("$path".to_string(), Value::String(last_segment.to_string()));
307                    } else {
308                        // Non-$ref (inline layout container): retain its literal structural
309                        // schema path so callers can identify its exact layout position.
310                        //
311                        // e.g. "#/illustration/$layout/elements" → "illustration.$layout.elements.1"
312                        let base = Self::layout_path_to_structural_path(layout_path);
313                        let fullpath = if base.is_empty() {
314                            format!("{}", element_idx)
315                        } else {
316                            format!("{}.{}", base, element_idx)
317                        };
318                        let last_segment =
319                            fullpath.split('.').last().unwrap_or(&fullpath).to_string();
320                        overlay.insert("$fullpath".to_string(), Value::String(fullpath));
321                        overlay.insert("$path".to_string(), Value::String(last_segment));
322                    }
323                }
324
325                // Override $parentHide — may exist from ref resolution as false,
326                // but tree_to_overlays owns the correct parent_hidden value.
327                overlay.insert("$parentHide".to_string(), Value::Bool(parent_hidden));
328
329                // ── Parent condition cascade ──
330                let mut element_hidden = parent_hidden;
331                let mut element_condition_hidden = parent_condition_hidden;
332                let mut element_disabled = parent_disabled;
333
334                // Element's own condition (from overlay now)
335                if let Some(Value::Object(cond)) = overlay.get("condition") {
336                    if let Some(Value::Bool(true)) = cond.get("hidden") {
337                        element_hidden = true;
338                        element_condition_hidden = true;
339                    }
340                    if let Some(Value::Bool(d)) = cond.get("disabled") {
341                        element_disabled = element_disabled || *d;
342                    }
343                }
344
345                // hideLayout affects presentation/output visibility, but not data-clearing policy.
346                if let Some(Value::Object(hide)) = overlay.get("hideLayout") {
347                    if let Some(Value::Bool(true)) = hide.get("all") {
348                        element_hidden = true;
349                    }
350                }
351
352                if !ref_path.is_empty() {
353                    let pointer = path_utils::normalize_to_json_pointer(
354                        &path_utils::dot_notation_to_schema_pointer(ref_path),
355                    )
356                    .trim_start_matches('#')
357                    .to_string();
358                    if element_hidden {
359                        layout_hidden_refs.insert(pointer.clone());
360                        if element_condition_hidden {
361                            layout_condition_hidden_refs.insert(pointer);
362                        }
363                    } else {
364                        layout_visible_refs.insert(pointer);
365                    }
366                }
367
368                // Only show condition cascade if parent has state OR element has state
369                let show_condition_cascade =
370                    parent_hidden || parent_disabled || element_hidden || element_disabled;
371
372                // $parentHide already set above
373
374                // ── Condition cascade: if parent OR element has state, emit merged condition ──
375                if show_condition_cascade {
376                    let mut merged_cond = serde_json::Map::new();
377                    if let Some(Value::Object(existing)) = overlay.get("condition") {
378                        for (k, v) in existing.iter() {
379                            merged_cond.insert(k.clone(), v.clone());
380                        }
381                    }
382                    // Merge in hidden states from parent and element
383                    if parent_hidden || element_hidden {
384                        merged_cond.insert("hidden".to_string(), Value::Bool(true));
385                    }
386                    if parent_disabled || element_disabled {
387                        merged_cond.insert("disabled".to_string(), Value::Bool(true));
388                    }
389                    overlay.insert("condition".to_string(), Value::Object(merged_cond));
390
391                    // Also push hideLayout cascade for layout containers
392                    if (parent_hidden || element_hidden)
393                        && (element.get("hideLayout").is_some() || element.get("type").is_some())
394                    {
395                        let mut hide_layout =
396                            if let Some(Value::Object(h)) = element.get("hideLayout") {
397                                h.clone()
398                            } else {
399                                serde_json::Map::new()
400                            };
401                        hide_layout.insert("all".to_string(), Value::Bool(true));
402                        overlay.insert("hideLayout".to_string(), Value::Object(hide_layout));
403                    }
404                }
405
406                // ── Recurse into nested elements ──
407                if let Some(Value::Array(children)) = element.get("elements") {
408                    // Build nested tree from children
409                    let child_tree: Vec<(Value, String)> = children
410                        .iter()
411                        .map(|c| {
412                            if let Value::Object(m) = c {
413                                (
414                                    c.clone(),
415                                    m.get("$fullpath")
416                                        .and_then(Value::as_str)
417                                        .unwrap_or("")
418                                        .to_string(),
419                                )
420                            } else {
421                                (c.clone(), String::new())
422                            }
423                        })
424                        .collect();
425
426                    let child_layout_path = format!(
427                        "{}/{}/elements",
428                        layout_path.trim_end_matches('/'),
429                        element_idx
430                    );
431
432                    // Recurse into children with element_hidden/element_disabled as parent state
433                    let child_overlays = Self::tree_to_overlays(
434                        &child_tree,
435                        &child_layout_path,
436                        element_hidden,
437                        element_condition_hidden,
438                        element_disabled,
439                        layout_hidden_refs,
440                        layout_visible_refs,
441                        layout_condition_hidden_refs,
442                    );
443                    entries.extend(child_overlays);
444                }
445            }
446
447            entries.push(LayoutOverlayEntry {
448                layout_path: layout_path.to_string(),
449                element_idx,
450                schema_ref_path: ref_path.clone(),
451                overlay,
452            });
453        }
454
455        entries
456    }
457
458    // ── Private helpers ──────────────────────────────────────────────────────
459
460    /// Convert a layout elements pointer to its literal dotted structural path.
461    ///
462    /// ## Examples
463    ///
464    /// ```text
465    /// "#/illustration/$layout/elements" → "illustration.$layout.elements"
466    /// "#/properties/form/$layout/elements" → "properties.form.$layout.elements"
467    /// ```
468    fn layout_path_to_structural_path(layout_path: &str) -> String {
469        layout_path
470            .trim_start_matches('#')
471            .trim_start_matches('/')
472            .split('/')
473            .filter(|segment| !segment.is_empty())
474            .collect::<Vec<_>>()
475            .join(".")
476    }
477}