Skip to main content

polyspec_crudui_validator/validate/
validator.rs

1//! CRUDUI form validator (SPEC §2 G5→§3→§2 G1, JS `Validator` parity).
2//!
3//! The third pass of the CRUDUI pipeline. It consumes a COMPOSED CRUDUI field model (the
4//! `validate`/`design`/`behavior`/`options` role slots) AFTER the compose pass has
5//! expanded `$ref`/`$patch`. Its field-model logic: (a) reading the `validate` slot
6//! (there is no `rules` key); (b) evaluating a rule value that is an expression OR a condition
7//! map (G1 — the condition is the value's expression, never a separate key); and
8//! (c) visibility from `design.show` alone: a hidden field and its descendants are
9//! not evaluated (`display_switch`/`display_target` are forbidden keys, G1).
10//!
11//! Byte-for-byte with the JS reference; the shared 4-language fixture
12//! `tests/fixtures/validate/cases.json` is the single source of truth. errors are
13//! collected in declaration/traversal order and NEVER reordered.
14
15use std::cell::RefCell;
16use std::collections::{HashMap, HashSet};
17
18use serde_json::{Map, Value};
19
20use crate::compose::{ComposeErrorCode, ComposeLoadError};
21use crate::expr::{Evaluator, Expression, Node};
22
23use super::choice_data::{check_single_choice_data, is_single_choice};
24use super::group_source::{group_choices, group_source_repeat, is_group_source, GroupChoicesError};
25use super::errors::{FormInputError, ValidateError};
26use super::keyed_maps::check_keyed_map_keys;
27use super::membership::Comparable;
28use super::parameters::{check, check_declared, Patterns};
29use super::rules::{get_rule, is_condition_expression, RuleContext};
30use super::visibility::is_visible;
31
32/// A single validation error (JS `ValidationError`: path/field/rule/message/value).
33#[derive(Debug, Clone, PartialEq)]
34pub struct ValidationError {
35    /// Dot-joined absolute path to the field (`items.1.code`, `rows.__a__.v`).
36    pub path: String,
37    /// The path's last segment.
38    pub field: String,
39    /// The failed rule's name (verbatim — `pattern`/`match` aliases distinct).
40    pub rule: String,
41    /// The display message (override → default with `{0}`/`{1}` → fallback).
42    pub message: String,
43    /// The failing value (the whole array for an array-level rule).
44    pub value: Value,
45}
46
47impl ValidationError {
48    /// Serialize to the cross-language JSON object shape.
49    pub fn to_value(&self) -> Value {
50        let mut m = Map::new();
51        m.insert("path".to_string(), Value::String(self.path.clone()));
52        m.insert("field".to_string(), Value::String(self.field.clone()));
53        m.insert("rule".to_string(), Value::String(self.rule.clone()));
54        m.insert("message".to_string(), Value::String(self.message.clone()));
55        m.insert("value".to_string(), self.value.clone());
56        Value::Object(m)
57    }
58}
59
60/// The validation result (JS `{ valid, errors, hidden }`).
61#[derive(Debug, Clone, PartialEq)]
62pub struct ValidationResult {
63    /// True iff there are no errors.
64    pub valid: bool,
65    /// Errors in traversal/declaration order (never reordered).
66    pub errors: Vec<ValidationError>,
67    /// Data paths of the fields whose `design.show` resolves to false, in
68    /// declaration order (validation rules, "Evaluation").
69    pub hidden: Vec<String>,
70}
71
72// ---------------------------------------------------------------------------
73// Rule-class tables (identical to the JS reference `Validator`).
74// ---------------------------------------------------------------------------
75
76/// Rules that apply to the whole array of a `multiple` field.
77const ARRAY_LEVEL_RULES: &[&str] = &["required", "unique", "mincount", "maxcount"];
78
79/// Rules whose param is a field reference / filter — preserved verbatim.
80const PATH_REFERENCE_RULES: &[&str] = &["equalTo", "containedIn", "typeFrom", "minFrom", "maxFrom", "notEqual", "unique", "enddate", "reference"];
81
82/// Rules whose param is a literal value, never a condition or a condition map.
83const LITERAL_PARAM_RULES: &[&str] = &["accept", "conditionTree"];
84
85/// Rules whose string param is a pattern preserved verbatim.
86const PATTERN_PARAM_RULES: &[&str] = &["match", "pattern"];
87
88/// Membership rules whose param is the allowed-value SET (an array, comma string,
89/// or a static value→label map, SPEC §2 G3). The param is data, NOT a
90/// condition map — an object param is the value→label map (key = option value,
91/// value = display label), kept verbatim and never evaluated key-by-key as
92/// expressions. The rule's flatten reads keys for a value→label map (the label,
93/// possibly a LangMap or null, is display-only).
94const MEMBERSHIP_PARAM_RULES: &[&str] = &["in"];
95
96/// The state of one `validate` call, dropped when it returns.
97pub(crate) struct Run<'a> {
98    /// The whole form data.
99    pub(crate) data: &'a Value,
100    /// For each collection field (container path, field name and filter), the row keys whose
101    /// `unique` value an earlier row already holds.
102    pub(crate) unique_duplicates: RefCell<HashMap<String, HashSet<String>>>,
103    /// The composed root field map, which a `reference` parameter names a field of.
104    pub(crate) properties: &'a Map<String, Value>,
105    /// For each declaration path that `reference` names, its ids in the data, collected once.
106    pub(crate) reference_ids: RefCell<HashMap<String, Vec<Comparable>>>,
107}
108
109/// The CRUDUI validator over a composed spec.
110pub struct Validator {
111    properties: Map<String, Value>,
112    patterns: Patterns,
113}
114
115impl Validator {
116    /// Build from a composed root group's `properties` map, checking every
117    /// declared rule parameter. A parameter outside the validation-rule
118    /// definitions returns a load failure located at the field's declaration path.
119    pub fn new(properties: Map<String, Value>) -> Result<Self, ValidateError> {
120        let patterns = check_declared(&properties)?;
121        Ok(Validator {
122            properties,
123            patterns,
124        })
125    }
126
127    /// Validate `data` against the composed CRUDUI spec. Root, group and repeated
128    /// data with the wrong shape return an input failure, and a parameter selected
129    /// by a condition that is outside the definitions returns a load failure;
130    /// neither produces a result.
131    pub fn validate(&self, data: &Value) -> Result<ValidationResult, ValidateError> {
132        if !data.is_object() {
133            return Err(FormInputError::new("Form data must be an object").into());
134        }
135        let mut errors: Vec<ValidationError> = Vec::new();
136        let run = Run {
137            data,
138            unique_duplicates: RefCell::new(HashMap::new()),
139            properties: &self.properties,
140            reference_ids: RefCell::new(HashMap::new()),
141        };
142        self.validate_properties(
143            &self.properties,
144            data,
145            &[],
146            &[],
147            &[],
148            &run,
149            false,
150            &mut errors,
151        )?;
152        let mut hidden = Vec::new();
153        collect_hidden(&self.properties, data, &[], &[], data, &mut hidden);
154        Ok(ValidationResult {
155            valid: errors.is_empty(),
156            errors,
157            hidden,
158        })
159    }
160
161    // =====================================================================
162    // Field traversal (SPEC §3).
163    // =====================================================================
164
165    /// `current_path` is the data path (with row keys); `declaration_path` is the
166    /// field's declaration path (without row keys); `hidden` says an enclosing field
167    /// is hidden, so only the data shape is checked.
168    #[allow(clippy::too_many_arguments, reason = "the walk of the validator passes its traversal state explicitly, as the engines of the other languages do")]
169    fn validate_properties(
170        &self,
171        properties: &Map<String, Value>,
172        data: &Value,
173        current_path: &[String],
174        row_keys: &[usize],
175        declaration_path: &[String],
176        run: &Run<'_>,
177        hidden: bool,
178        errors: &mut Vec<ValidationError>,
179    ) -> Result<(), ValidateError> {
180        if let Some(object) = data.as_object() {
181            let mut unknown: Vec<&String> = object
182                .keys()
183                .filter(|name| !properties.contains_key(name.as_str()))
184                .collect();
185            unknown.sort();
186            if let Some(name) = unknown.first() {
187                let mut path = current_path.to_vec();
188                path.push((*name).clone());
189                return Err(FormInputError::new(format!(
190                    "Unknown form data field: {}",
191                    path_to_string(&path)
192                ))
193                .into());
194            }
195        }
196        let all_data = run.data;
197        let empty = Value::Object(Map::new());
198        for (property_key, field) in properties {
199            if !field.is_object() {
200                continue;
201            }
202            let is_multiple = is_multiple(field);
203            let mut field_path = current_path.to_vec();
204            field_path.push(property_key.clone());
205            let mut declaration = declaration_path.to_vec();
206            declaration.push(property_key.clone());
207            let declaration = declaration.as_slice();
208            // The rules of a hidden field and of everything it contains are not
209            // evaluated; the data shape is checked all the same.
210            let hidden = hidden || !is_visible(field, all_data, &field_path, row_keys);
211            let present = data.get(property_key);
212            let field_value = present.cloned().unwrap_or(Value::Null);
213
214            if is_multiple && present.is_some_and(|value| !value.is_object()) {
215                return Err(FormInputError::new(format!(
216                    "Repeated data must be a keyed object: {}",
217                    path_to_string(&field_path)
218                ))
219                .into());
220            }
221            if let Some(rows) = present.and_then(Value::as_object).filter(|_| is_multiple) {
222                check_keyed_map_keys(field, rows, &field_path, row_keys, all_data)?;
223            }
224            if let Some(value) = present.filter(|_| is_single_choice(field)) {
225                check_single_choice_data(value, &field_path, is_multiple)?;
226            }
227
228            if let Some(child_props) = is_group_with_properties(field) {
229                if is_multiple {
230                    // Missing data is an empty collection: no rows.
231                    if let Some(Value::Object(rows)) = present {
232                        // Keyed rows use sorted-key traversal so the first reported
233                        // error is identical in every validation implementation.
234                        let mut keys: Vec<&String> = rows.keys().collect();
235                        keys.sort();
236                        for key in keys {
237                            let mut row_path = field_path.clone();
238                            row_path.push(key.clone());
239                            let row = &rows[key.as_str()];
240                            if !row.is_object() {
241                                return Err(FormInputError::new(format!(
242                                    "Group data must be an object: {}",
243                                    path_to_string(&row_path)
244                                ))
245                                .into());
246                            }
247                            let mut row_rows = row_keys.to_vec();
248                            row_rows.push(field_path.len());
249                            self.validate_properties(
250                                child_props,
251                                row,
252                                &row_path,
253                                &row_rows,
254                                declaration,
255                                run,
256                                hidden,
257                                errors,
258                            )?;
259                        }
260                    }
261                    if !hidden {
262                        errors.extend(self.validate_field_rules(
263                            field,
264                            &field_value,
265                            &field_path,
266                            row_keys,
267                            declaration,
268                            run,
269                            false,
270                        )?);
271                    }
272                } else {
273                    if present.is_some_and(|value| !value.is_object()) {
274                        return Err(FormInputError::new(format!(
275                            "Group data must be an object: {}",
276                            path_to_string(&field_path)
277                        ))
278                        .into());
279                    }
280                    let nested = present.unwrap_or(&empty);
281                    self.validate_properties(
282                        child_props,
283                        nested,
284                        &field_path,
285                        row_keys,
286                        declaration,
287                        run,
288                        hidden,
289                        errors,
290                    )?;
291                    if !hidden {
292                        errors.extend(self.validate_field_rules(
293                            field,
294                            &field_value,
295                            &field_path,
296                            row_keys,
297                            declaration,
298                            run,
299                            false,
300                        )?);
301                    }
302                }
303            } else if hidden {
304                // A hidden scalar field has no rules to run and no shape below it.
305            } else if is_multiple {
306                // Missing data is an empty collection: no rows.
307                let no_rows = Map::new();
308                let rows = match present {
309                    Some(Value::Object(rows)) => rows,
310                    _ => &no_rows,
311                };
312                self.validate_multiple_field_rules(
313                    field,
314                    &field_value,
315                    rows,
316                    &field_path,
317                    row_keys,
318                    declaration,
319                    run,
320                    errors,
321                )?;
322            } else {
323                errors.extend(self.validate_field_rules(
324                    field,
325                    &field_value,
326                    &field_path,
327                    row_keys,
328                    declaration,
329                    run,
330                    false,
331                )?);
332            }
333        }
334        Ok(())
335    }
336
337    // =====================================================================
338    // validate-slot evaluation (SPEC §3 slots.validate).
339    // =====================================================================
340
341    /// Collection rules and per-row rules for a repeated scalar field.
342    #[allow(clippy::too_many_arguments, reason = "the walk of the validator passes its traversal state explicitly, as the engines of the other languages do")]
343    fn validate_multiple_field_rules(
344        &self,
345        field: &Value,
346        values: &Value,
347        rows: &Map<String, Value>,
348        field_path: &[String],
349        row_keys: &[usize],
350        declaration: &[String],
351        run: &Run<'_>,
352        errors: &mut Vec<ValidationError>,
353    ) -> Result<(), ValidateError> {
354        let messages = field.get("messages");
355        let mut entries: Vec<(String, &Value)> = rows.iter().map(|(k, v)| (k.clone(), v)).collect();
356        entries.sort_by(|a, b| a.0.cmp(&b.0));
357
358        // 1. Array-level rules in declaration order; first error wins.
359        if let Some(rules) = normalize_validate_slot(field) {
360            for (rule_name, rule_value) in rules {
361                if !ARRAY_LEVEL_RULES.contains(&rule_name.as_str()) {
362                    continue;
363                }
364                if let Some(message) = self.run_rule(
365                    rule_name,
366                    rule_value,
367                    values,
368                    field_path,
369                    row_keys,
370                    declaration,
371                    messages,
372                    run,
373                )? {
374                    errors.push(ValidationError {
375                        path: path_to_string(field_path),
376                        field: field_name(field_path),
377                        rule: rule_name.clone(),
378                        message,
379                        value: values.clone(),
380                    });
381                    return Ok(());
382                }
383            }
384        }
385
386        // 2. Row rules in sorted row-key order.
387        for (key, value) in entries {
388            let mut item_path = field_path.to_vec();
389            item_path.push(key);
390            let mut item_rows = row_keys.to_vec();
391            item_rows.push(field_path.len());
392            errors.extend(self.validate_field_rules(
393                field,
394                value,
395                &item_path,
396                &item_rows,
397                declaration,
398                run,
399                true,
400            )?);
401        }
402        Ok(())
403    }
404
405    /// All rules for a single (scalar or group-as-whole) field, or, with `element_level`, the
406    /// element-level rules of one element of a `multiple` field (no array-level rule).
407    #[allow(clippy::too_many_arguments, reason = "the walk of the validator passes its traversal state explicitly, as the engines of the other languages do")]
408    fn validate_field_rules(
409        &self,
410        field: &Value,
411        value: &Value,
412        field_path: &[String],
413        row_keys: &[usize],
414        declaration: &[String],
415        run: &Run<'_>,
416        element_level: bool,
417    ) -> Result<Option<ValidationError>, ValidateError> {
418        let messages = field.get("messages");
419        let rules = normalize_validate_slot(field);
420
421        if let Some(error) =
422            self.run_implicit_number(field, rules, value, field_path, row_keys, messages, run)?
423        {
424            return Ok(Some(error));
425        }
426        if let Some(error) =
427            self.run_group_source(field, value, field_path, row_keys, declaration, messages, run)?
428        {
429            return Ok(Some(error));
430        }
431        let rules = match rules {
432            Some(r) => r,
433            None => return Ok(None),
434        };
435        for (rule_name, rule_value) in rules {
436            if element_level && ARRAY_LEVEL_RULES.contains(&rule_name.as_str()) {
437                continue;
438            }
439            if let Some(message) = self.run_rule(
440                rule_name,
441                rule_value,
442                value,
443                field_path,
444                row_keys,
445                declaration,
446                messages,
447                run,
448            )? {
449                return Ok(Some(ValidationError {
450                    path: path_to_string(field_path),
451                    field: field_name(field_path),
452                    rule: rule_name.clone(),
453                    message,
454                    value: value.clone(),
455                }));
456            }
457        }
458        Ok(None)
459    }
460
461    /// A field whose `items` is a group source runs an implicit `in` check against the choices
462    /// that the source gives for the root data, before its declared rules (validation-rules.md,
463    /// "Values"). Returns the error of the check when it fails.
464    #[allow(clippy::too_many_arguments, reason = "the walk of the validator passes its traversal state explicitly, as the engines of the other languages do")]
465    fn run_group_source(
466        &self,
467        field: &Value,
468        value: &Value,
469        path: &[String],
470        row_keys: &[usize],
471        declaration: &[String],
472        messages: Option<&Value>,
473        run: &Run<'_>,
474    ) -> Result<Option<ValidationError>, ValidateError> {
475        let Some(items) = field.get("items").filter(|items| is_group_source(items)) else {
476            return Ok(None);
477        };
478        // A repeated value among the source rows is data that the choices cannot express (CUVR-087).
479        if let Some(repeat) = group_source_repeat(items, run.data) {
480            return Err(FormInputError::new(repeat).into());
481        }
482        let choices = group_choices(items, run.data, &declaration.join(".")).map_err(|error| match error {
483            GroupChoicesError::Declaration(message) => ValidateError::Load(ComposeLoadError::with_trace(
484                ComposeErrorCode::InvalidRuleParameter,
485                message,
486                declaration.to_vec(),
487            )),
488            GroupChoicesError::Repeated(message) => ValidateError::Input(FormInputError::new(message)),
489        })?;
490        let members = Value::Array(
491            choices
492                .into_iter()
493                .map(|choice| Value::String(choice.value))
494                .collect(),
495        );
496        let Some(message) =
497            self.run_rule("in", &members, value, path, row_keys, declaration, messages, run)?
498        else {
499            return Ok(None);
500        };
501        Ok(Some(ValidationError {
502            path: path_to_string(path),
503            field: field_name(path),
504            rule: "in".to_string(),
505            message,
506            value: value.clone(),
507        }))
508    }
509
510    /// `type:number` runs an implicit `number` rule first when no explicit
511    /// `number` rule is declared. Returns the error of the rule when it fails.
512    #[allow(clippy::too_many_arguments, reason = "the walk of the validator passes its traversal state explicitly, as the engines of the other languages do")]
513    fn run_implicit_number(
514        &self,
515        field: &Value,
516        rules: Option<&Map<String, Value>>,
517        value: &Value,
518        path: &[String],
519        row_keys: &[usize],
520        messages: Option<&Value>,
521        run: &Run<'_>,
522    ) -> Result<Option<ValidationError>, ValidateError> {
523        if field.get("type").and_then(Value::as_str) != Some("number") {
524            return Ok(None);
525        }
526        if rules.map(|r| r.contains_key("number")).unwrap_or(false) {
527            return Ok(None);
528        }
529        // The implicit `number` parameter is `true`; it has nothing to check.
530        if let Some(message) = self.run_rule(
531            "number",
532            &Value::Bool(true),
533            value,
534            path,
535            row_keys,
536            &[],
537            messages,
538            run,
539        )? {
540            return Ok(Some(ValidationError {
541                path: path_to_string(path),
542                field: field_name(path),
543                rule: "number".to_string(),
544                message,
545                value: value.clone(),
546            }));
547        }
548        Ok(None)
549    }
550
551    // =====================================================================
552    // Conditional rule value (G1) — expression / condition map → param.
553    // =====================================================================
554
555    /// Run one rule: resolve its (possibly conditional) value to an effective
556    /// param, skip on false/null, check the parameter, else call the rule.
557    /// Returns the error message, `None` on a pass, or the failure of a selected
558    /// parameter located at `declaration`.
559    #[allow(clippy::too_many_arguments, reason = "the walk of the validator passes its traversal state explicitly, as the engines of the other languages do")]
560    fn run_rule(
561        &self,
562        rule_name: &str,
563        rule_value: &Value,
564        value: &Value,
565        current_path: &[String],
566        row_keys: &[usize],
567        declaration: &[String],
568        messages: Option<&Value>,
569        run: &Run<'_>,
570    ) -> Result<Option<String>, ValidateError> {
571        let all_data = run.data;
572        let effective =
573            self.resolve_rule_value(rule_name, rule_value, current_path, row_keys, all_data);
574
575        // A false/null effective param disables the rule.
576        if is_disabled(&effective) {
577            return Ok(None);
578        }
579
580        // Rule names are checked when the specification loads.
581        let Some(rule_fn) = get_rule(rule_name) else {
582            unreachable!("rule {rule_name} is not registered");
583        };
584
585        // Declared parameters were checked when the specification loaded (patterns
586        // are compiled then); a selected parameter is checked now.
587        let parameter = check(rule_name, &effective, declaration, &self.patterns)?;
588
589        let ctx = RuleContext {
590            value,
591            rule_param: &effective,
592            parameter: &parameter,
593            messages,
594            rule_name,
595            path_segments: current_path,
596            row_keys,
597            form_data: all_data,
598            run,
599        };
600        Ok(rule_fn(&ctx))
601    }
602
603    /// Resolve a rule value to the effective param (G1).
604    fn resolve_rule_value(
605        &self,
606        rule_name: &str,
607        rule_value: &Value,
608        current_path: &[String],
609        row_keys: &[usize],
610        all_data: &Value,
611    ) -> Value {
612        match resolution(rule_name, rule_value) {
613            Resolution::Verbatim | Resolution::Literal => rule_value.clone(),
614            Resolution::ConditionMap(map) => {
615                self.resolve_condition_map(map, current_path, row_keys, all_data)
616            }
617            Resolution::Ternary(node) => {
618                Evaluator::new(all_data, current_path, row_keys).evaluate_value(&node)
619            }
620            Resolution::Expression(expression) => {
621                evaluate_expression_value(expression, all_data, current_path, row_keys)
622            }
623        }
624    }
625
626    /// Evaluate a ConditionMap (expressions.md §8): first truthy key wins;
627    /// else the `true` key; else null (disabled).
628    fn resolve_condition_map(
629        &self,
630        map: &Map<String, Value>,
631        current_path: &[String],
632        row_keys: &[usize],
633        all_data: &Value,
634    ) -> Value {
635        for (key, val) in map {
636            if key == "true" {
637                continue;
638            }
639            if evaluate_condition(key, all_data, current_path, row_keys) {
640                return val.clone();
641            }
642        }
643        if let Some(v) = map.get("true") {
644            return v.clone();
645        }
646        Value::Null
647    }
648}
649
650// ---------------------------------------------------------------------------
651// How a rule value becomes its parameter (shared with the parameter checks).
652// ---------------------------------------------------------------------------
653
654/// How a declared rule value becomes the rule's parameter.
655enum Resolution<'a> {
656    /// A rule that receives its value unchanged.
657    Verbatim,
658    /// A condition map selecting a value.
659    ConditionMap(&'a Map<String, Value>),
660    /// A ternary expression selecting a branch value.
661    Ternary(Node),
662    /// A condition expression whose value is the parameter.
663    Expression(&'a str),
664    /// A literal parameter.
665    Literal,
666}
667
668fn resolution<'a>(rule_name: &str, rule_value: &'a Value) -> Resolution<'a> {
669    // Verbatim-param rules: never evaluate (field ref / literal / pattern /
670    // membership set). A membership param object is a value→label map (G3),
671    // not a condition map.
672    if PATH_REFERENCE_RULES.contains(&rule_name)
673        || LITERAL_PARAM_RULES.contains(&rule_name)
674        || PATTERN_PARAM_RULES.contains(&rule_name)
675        || MEMBERSHIP_PARAM_RULES.contains(&rule_name)
676    {
677        return Resolution::Verbatim;
678    }
679    match rule_value {
680        Value::Object(map) => Resolution::ConditionMap(map),
681        Value::String(s) => {
682            // A complete ternary AST selects a branch; other strings are
683            // condition expressions or literal parameters.
684            if let Ok(node @ Node::Ternary { .. }) = Expression::parse(s) {
685                Resolution::Ternary(node)
686            } else if is_condition_expression(s) && !has_ternary_regex(s) {
687                Resolution::Expression(s)
688            } else {
689                Resolution::Literal
690            }
691        }
692        _ => Resolution::Literal,
693    }
694}
695
696/// Whether a declared rule value is selected by a condition, so its parameter is
697/// checked when it is selected rather than when the specification loads.
698pub(crate) fn is_conditional(rule_name: &str, rule_value: &Value) -> bool {
699    matches!(
700        resolution(rule_name, rule_value),
701        Resolution::ConditionMap(_) | Resolution::Ternary(_) | Resolution::Expression(_)
702    )
703}
704
705/// The literals a conditional rule value can select, which are checked when the
706/// specification loads: every condition-map value, and every literal ternary
707/// branch (through nested ternaries). A value a branch takes from the data is
708/// checked only when it is selected.
709pub(crate) fn selectable_literals(rule_name: &str, rule_value: &Value) -> Vec<Value> {
710    let mut literals = Vec::new();
711    match resolution(rule_name, rule_value) {
712        Resolution::ConditionMap(map) => literals.extend(map.values().cloned()),
713        Resolution::Ternary(node) => {
714            let mut pending = vec![&node];
715            while let Some(node) = pending.pop() {
716                match node {
717                    Node::Ternary {
718                        true_value,
719                        false_value,
720                        ..
721                    } => {
722                        // The true branch is collected first.
723                        pending.push(false_value);
724                        pending.push(true_value);
725                    }
726                    Node::Group(inner) => pending.push(inner),
727                    Node::Literal(literal) => literals.push(literal.to_value()),
728                    _ => {}
729                }
730            }
731        }
732        Resolution::Verbatim | Resolution::Expression(_) | Resolution::Literal => {}
733    }
734    literals
735}
736
737/// Whether a parameter disables its rule (`false` or `null`).
738pub(crate) fn is_disabled(parameter: &Value) -> bool {
739    matches!(parameter, Value::Bool(false) | Value::Null)
740}
741
742/// Append the data paths of the fields whose `design.show` resolves to false, in
743/// declaration order, each field of a group row under the row's key and the fields
744/// inside a hidden field included. Data of another shape is read as missing.
745fn collect_hidden(
746    properties: &Map<String, Value>,
747    data: &Value,
748    current_path: &[String],
749    row_keys: &[usize],
750    all_data: &Value,
751    hidden: &mut Vec<String>,
752) {
753    for (name, field) in properties {
754        if !field.is_object() {
755            continue;
756        }
757        let mut field_path = current_path.to_vec();
758        field_path.push(name.clone());
759        if !is_visible(field, all_data, &field_path, row_keys) {
760            hidden.push(path_to_string(&field_path));
761        }
762        let Some(children) = is_group_with_properties(field) else {
763            continue;
764        };
765        let empty = Value::Object(Map::new());
766        let value = data
767            .get(name)
768            .filter(|value| value.is_object())
769            .unwrap_or(&empty);
770        if !is_multiple(field) {
771            collect_hidden(children, value, &field_path, row_keys, all_data, hidden);
772            continue;
773        }
774        let rows = value.as_object().expect("an object");
775        let mut keys: Vec<&String> = rows.keys().collect();
776        keys.sort();
777        for key in keys {
778            let mut row_path = field_path.clone();
779            row_path.push(key.clone());
780            let mut row_rows = row_keys.to_vec();
781            row_rows.push(field_path.len());
782            let row = Some(&rows[key.as_str()])
783                .filter(|row| row.is_object())
784                .unwrap_or(&empty);
785            collect_hidden(children, row, &row_path, &row_rows, all_data, hidden);
786        }
787    }
788}
789
790/// The member declarations of a group field the validator descends into.
791pub(crate) fn is_group_with_properties(field: &Value) -> Option<&Map<String, Value>> {
792    if field.get("type").and_then(Value::as_str) != Some("group") {
793        return None;
794    }
795    field.get("properties").and_then(Value::as_object)
796}
797
798// ---------------------------------------------------------------------------
799// expression engine adapters (JS evaluateCondition / evaluateExpressionValue).
800// ---------------------------------------------------------------------------
801
802/// A condition that does not parse is not a condition: the JS reference
803/// `evaluateCondition` catches the parse error and returns false
804/// (packages/validator/ts/src/validate/validator.ts).
805#[allow(clippy::disallowed_methods, reason = "a condition that does not parse is false")]
806fn evaluate_condition(
807    expression: &str,
808    form_data: &Value,
809    current_path: &[String],
810    row_keys: &[usize],
811) -> bool {
812    Expression::evaluate(expression, form_data, current_path, row_keys).unwrap_or(false)
813}
814
815/// A value expression that does not parse is false: the JS reference
816/// `evaluateExpressionValue` catches the parse error and returns false
817/// (packages/validator/ts/src/validate/validator.ts).
818#[allow(clippy::disallowed_methods, reason = "a value expression that does not parse is false")]
819fn evaluate_expression_value(
820    expression: &str,
821    form_data: &Value,
822    current_path: &[String],
823    row_keys: &[usize],
824) -> Value {
825    Expression::evaluate_value(expression, form_data, current_path, row_keys)
826        .unwrap_or(Value::Bool(false))
827}
828
829// ---------------------------------------------------------------------------
830// field helpers.
831// ---------------------------------------------------------------------------
832
833/// Whether a field repeats: `multiple` is `true`, `only` or a settings object.
834pub(crate) fn is_multiple(field: &Value) -> bool {
835    match field.get("multiple") {
836        Some(Value::Bool(true)) | Some(Value::Object(_)) => true,
837        Some(Value::String(keyword)) => keyword == "only",
838        _ => false,
839    }
840}
841
842/// Normalize a polymorphic `validate` slot to a rule map. false/true/{}/absent →
843/// None; an object → the rule map.
844fn normalize_validate_slot(field: &Value) -> Option<&Map<String, Value>> {
845    match field.get("validate") {
846        Some(Value::Object(m)) => Some(m),
847        _ => None,
848    }
849}
850
851// ---------------------------------------------------------------------------
852// Path utilities (JS pathToString / getFieldName).
853// ---------------------------------------------------------------------------
854
855pub(crate) fn path_to_string(path: &[String]) -> String {
856    path.join(".")
857}
858
859fn field_name(path: &[String]) -> String {
860    path.last().cloned().unwrap_or_default()
861}
862
863// ---------------------------------------------------------------------------
864// Identify strings containing ternary punctuation.
865// ---------------------------------------------------------------------------
866
867/// JS `/\?[^:]*:/` test: a '?' that has a ':' somewhere after it.
868pub(crate) fn has_ternary_regex(s: &str) -> bool {
869    if let Some(q) = s.find('?') {
870        return s[q + 1..].contains(':');
871    }
872    false
873}