Skip to main content

yaml_schema/schemas/
array.rs

1use std::collections::HashSet;
2use std::fmt::Display;
3
4use log::debug;
5use saphyr::AnnotatedMapping;
6use saphyr::MarkedYaml;
7use saphyr::Scalar;
8use saphyr::YamlData;
9
10use crate::Context;
11use crate::Result;
12use crate::Validator;
13use crate::YamlSchema;
14use crate::loader;
15use crate::schemas::BooleanOrSchema;
16use crate::utils::format_marker;
17use crate::utils::format_vec;
18use crate::utils::format_yaml_data;
19
20/// An array schema represents an array
21#[derive(Debug, Default, PartialEq)]
22pub struct ArraySchema {
23    pub items: Option<BooleanOrSchema>,
24    pub prefix_items: Option<Vec<YamlSchema>>,
25    pub min_items: Option<usize>,
26    pub max_items: Option<usize>,
27    pub unique_items: Option<bool>,
28    pub contains: Option<YamlSchema>,
29    pub min_contains: Option<u64>,
30    pub max_contains: Option<u64>,
31}
32
33impl<'r> TryFrom<&AnnotatedMapping<'r, MarkedYaml<'r>>> for ArraySchema {
34    type Error = crate::Error;
35
36    fn try_from(mapping: &AnnotatedMapping<'r, MarkedYaml<'r>>) -> crate::Result<Self> {
37        let mut array_schema = ArraySchema::default();
38        for (key, value) in mapping.iter() {
39            if let YamlData::Value(Scalar::String(s)) = &key.data {
40                match s.as_ref() {
41                    "contains" => {
42                        if value.data.is_mapping() {
43                            let yaml_schema = value.try_into()?;
44                            array_schema.contains = Some(yaml_schema);
45                        } else {
46                            return Err(generic_error!(
47                                "contains: expected a mapping, but got: {:?}",
48                                value
49                            ));
50                        }
51                    }
52                    "items" => {
53                        let array_items = loader::load_array_items_marked(value)?;
54                        array_schema.items = Some(array_items);
55                    }
56                    "type" => {
57                        if let YamlData::Value(Scalar::String(s)) = &value.data {
58                            if s != "array" {
59                                return Err(unsupported_type!(
60                                    "Expected type: array, but got: {}",
61                                    s
62                                ));
63                            }
64                        } else {
65                            return Err(expected_type_is_string!(value));
66                        }
67                    }
68                    "prefixItems" => {
69                        let prefix_items = loader::load_array_of_schemas_marked(value)?;
70                        array_schema.prefix_items = Some(prefix_items);
71                    }
72                    "minContains" => {
73                        let n = loader::load_integer_marked(value)?;
74                        if n < 0 {
75                            return Err(generic_error!(
76                                "{} minContains must be a non-negative integer, got: {}",
77                                format_marker(&value.span.start),
78                                n
79                            ));
80                        }
81                        array_schema.min_contains = Some(n as u64);
82                    }
83                    "maxContains" => {
84                        let n = loader::load_integer_marked(value)?;
85                        if n < 0 {
86                            return Err(generic_error!(
87                                "{} maxContains must be a non-negative integer, got: {}",
88                                format_marker(&value.span.start),
89                                n
90                            ));
91                        }
92                        array_schema.max_contains = Some(n as u64);
93                    }
94                    "minItems" => {
95                        if let Ok(i) = loader::load_integer_marked(value) {
96                            array_schema.min_items = Some(i as usize);
97                        } else {
98                            return Err(unsupported_type!(
99                                "minItems expected integer, but got: {:?}",
100                                value
101                            ));
102                        }
103                    }
104                    "maxItems" => {
105                        if let Ok(i) = loader::load_integer_marked(value) {
106                            array_schema.max_items = Some(i as usize);
107                        } else {
108                            return Err(unsupported_type!(
109                                "maxItems expected integer, but got: {:?}",
110                                value
111                            ));
112                        }
113                    }
114                    "uniqueItems" => {
115                        if let YamlData::Value(Scalar::Boolean(b)) = &value.data {
116                            array_schema.unique_items = Some(*b);
117                        } else {
118                            return Err(unsupported_type!(
119                                "uniqueItems expected boolean, but got: {:?}",
120                                value
121                            ));
122                        }
123                    }
124                    "unevaluatedItems" => {
125                        // Loaded on `Subschema`; ignore here when parsing `type: array` mapping.
126                    }
127                    _ => debug!("Unsupported key for ArraySchema: {}", s),
128                }
129            } else {
130                return Err(generic_error!(
131                    "{} Expected scalar key, got: {:?}",
132                    format_marker(&key.span.start),
133                    key
134                ));
135            }
136        }
137        Ok(array_schema)
138    }
139}
140
141impl Validator for ArraySchema {
142    fn validate(&self, context: &Context, value: &saphyr::MarkedYaml) -> Result<()> {
143        debug!("[ArraySchema] self: {self:?}");
144        let data = &value.data;
145        debug!("[ArraySchema] Validating value: {}", format_yaml_data(data));
146
147        if let saphyr::YamlData::Sequence(array) = data {
148            let err_after_meta = context.errors.borrow().len();
149
150            // validate contains with minContains / maxContains
151            if let Some(min_items) = self.min_items
152                && array.len() < min_items
153            {
154                context.add_error(
155                    value,
156                    format!(
157                        "Array has too few items (minimum {min_items}, found {})",
158                        array.len()
159                    ),
160                );
161                fail_fast!(context);
162            }
163            if let Some(max_items) = self.max_items
164                && array.len() > max_items
165            {
166                context.add_error(
167                    value,
168                    format!(
169                        "Array has too many items (maximum {max_items}, found {})",
170                        array.len()
171                    ),
172                );
173                fail_fast!(context);
174            }
175
176            if self.unique_items == Some(true) {
177                let mut seen = HashSet::with_capacity(array.len());
178                for item in array {
179                    if !seen.insert(item) {
180                        context.add_error(
181                            item,
182                            format!("Duplicate array element: {}", format_yaml_data(&item.data)),
183                        );
184                        fail_fast!(context);
185                    }
186                }
187            }
188
189            // validate contains
190            if let Some(sub_schema) = &self.contains {
191                let match_count = array
192                    .iter()
193                    .filter(|item| {
194                        let sub_context = crate::Context {
195                            root_schema: context.root_schema,
196                            fail_fast: true,
197                            ..Default::default()
198                        };
199                        sub_schema.validate(&sub_context, item).is_ok() && !sub_context.has_errors()
200                    })
201                    .count() as u64;
202
203                let min = self.min_contains.unwrap_or(1);
204                if match_count < min {
205                    context.add_error(
206                        value,
207                        format!(
208                            "Array must contain at least {min} item(s) matching the contains schema, but only {match_count} matched"
209                        ),
210                    );
211                }
212                if let Some(max) = self.max_contains
213                    && match_count > max
214                {
215                    context.add_error(
216                        value,
217                        format!(
218                            "Array must contain at most {max} item(s) matching the contains schema, but {match_count} matched"
219                        ),
220                    );
221                }
222            }
223
224            // validate prefix items
225            if let Some(prefix_items) = &self.prefix_items {
226                debug!(
227                    "[ArraySchema] Validating prefix items: {}",
228                    format_vec(prefix_items)
229                );
230                for (i, item) in array.iter().enumerate() {
231                    // if the index is within the prefix items, validate against the prefix items schema
232                    if i < prefix_items.len() {
233                        debug!(
234                            "[ArraySchema] Validating prefix item {} with schema: {}",
235                            i, prefix_items[i]
236                        );
237                        prefix_items[i].validate(context, item)?;
238                    } else if let Some(items) = &self.items {
239                        // if the index is not within the prefix items, validate against the array items schema
240                        debug!("[ArraySchema] Validating array item {i} with schema: {items}");
241                        match items {
242                            BooleanOrSchema::Boolean(true) => {
243                                // `items: true` allows any items
244                                break;
245                            }
246                            BooleanOrSchema::Boolean(false) => {
247                                context.add_error(
248                                    item,
249                                    "Additional array items are not allowed!".to_string(),
250                                );
251                            }
252                            BooleanOrSchema::Schema(yaml_schema) => {
253                                yaml_schema.validate(context, item)?;
254                            }
255                        }
256                    } else {
257                        break;
258                    }
259                }
260            } else {
261                // validate array items
262                if let Some(items) = &self.items {
263                    match items {
264                        BooleanOrSchema::Boolean(true) => { /* no-op */ }
265                        BooleanOrSchema::Boolean(false) => {
266                            if self.prefix_items.is_none() && !array.is_empty() {
267                                context
268                                    .add_error(value, "Array items are not allowed!".to_string());
269                            }
270                        }
271                        BooleanOrSchema::Schema(yaml_schema) => {
272                            for item in array {
273                                yaml_schema.validate(context, item)?;
274                            }
275                        }
276                    }
277                }
278            }
279
280            if context.errors.borrow().len() == err_after_meta {
281                Self::record_unevaluated_array_annotations(self, context, array);
282            }
283
284            Ok(())
285        } else {
286            debug!("[ArraySchema] context.fail_fast: {}", context.fail_fast);
287            context.add_error(
288                value,
289                format!(
290                    "Expected an array, but got: {}",
291                    format_yaml_data(&value.data)
292                ),
293            );
294            fail_fast!(context);
295            Ok(())
296        }
297    }
298}
299
300impl ArraySchema {
301    /// Update [`Context::array_unevaluated`] from this schema's `prefixItems` / `items` / `contains` (2020-12).
302    fn record_unevaluated_array_annotations(
303        schema: &ArraySchema,
304        context: &Context,
305        array: &[MarkedYaml],
306    ) {
307        let Some(cell) = context.array_unevaluated.as_ref() else {
308            return;
309        };
310        let mut ann = cell.borrow_mut();
311
312        if let Some(sub_schema) = &schema.contains {
313            ann.saw_relevant = true;
314            if array.is_empty() {
315                // Annotation still present for empty instance (Core §10.3.1.3).
316            } else {
317                let mut matching = HashSet::new();
318                for (i, item) in array.iter().enumerate() {
319                    let sub_context = Context {
320                        root_schema: context.root_schema,
321                        fail_fast: true,
322                        ..Default::default()
323                    };
324                    if sub_schema.validate(&sub_context, item).is_ok() && !sub_context.has_errors()
325                    {
326                        matching.insert(i);
327                    }
328                }
329                if matching.len() == array.len() {
330                    ann.contains_all = true;
331                } else {
332                    ann.contains_indices.extend(matching);
333                }
334            }
335        }
336
337        if let Some(prefix_items) = &schema.prefix_items
338            && !prefix_items.is_empty()
339            && !array.is_empty()
340        {
341            let n = array.len().min(prefix_items.len());
342            if n > 0 {
343                ann.saw_relevant = true;
344                let largest = n - 1;
345                ann.prefix_largest = Some(match ann.prefix_largest {
346                    Some(p) => p.max(largest),
347                    None => largest,
348                });
349            }
350        }
351
352        let prefix_len = schema.prefix_items.as_ref().map(|p| p.len()).unwrap_or(0);
353        let tail_non_empty = array.len() > prefix_len;
354        let items_covers_all = prefix_len == 0 && !array.is_empty();
355
356        if let Some(items) = &schema.items {
357            match items {
358                BooleanOrSchema::Boolean(true) => {
359                    if tail_non_empty || items_covers_all {
360                        ann.saw_relevant = true;
361                        ann.full_coverage = true;
362                    }
363                }
364                BooleanOrSchema::Schema(_) => {
365                    if tail_non_empty || items_covers_all {
366                        ann.saw_relevant = true;
367                        ann.full_coverage = true;
368                    }
369                }
370                BooleanOrSchema::Boolean(false) => {}
371            }
372        }
373    }
374}
375
376impl Display for ArraySchema {
377    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378        write!(
379            f,
380            "Array{{ items: {:?}, prefix_items: {:?}, min_items: {:?}, max_items: {:?}, unique_items: {:?}}}, contains: {:?}, min_contains: {:?}, max_contains: {:?}}}",
381            self.items,
382            self.prefix_items,
383            self.min_items,
384            self.max_items,
385            self.unique_items,
386            self.contains,
387            self.min_contains,
388            self.max_contains
389        )
390    }
391}
392#[cfg(test)]
393mod tests {
394    use crate::schemas::NumberSchema;
395    use crate::schemas::StringSchema;
396    use saphyr::LoadableYamlNode;
397
398    use super::*;
399
400    #[test]
401    fn test_array_schema_prefix_items() {
402        let schema = ArraySchema {
403            prefix_items: Some(vec![YamlSchema::typed_number(NumberSchema::default())]),
404            items: Some(BooleanOrSchema::schema(YamlSchema::typed_string(
405                StringSchema::default(),
406            ))),
407            ..Default::default()
408        };
409        let s = r#"
410        - 1
411        - 2
412        - Washington
413        "#;
414        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
415        let value = docs.first().unwrap();
416        let context = crate::Context::default();
417        let result = schema.validate(&context, value);
418        assert!(result.is_ok());
419    }
420
421    #[test]
422    fn test_array_schema_prefix_items_from_yaml() {
423        let schema_string = "
424      type: array
425      prefixItems:
426        - type: number
427        - type: string
428        - enum:
429          - Street
430          - Avenue
431          - Boulevard
432        - enum:
433          - NW
434          - NE
435          - SW
436          - SE
437      items:
438        type: string
439";
440
441        let yaml_string = r#"
442        - 1600
443        - Pennsylvania
444        - Avenue
445        - NW
446        - Washington
447        "#;
448
449        let s_docs = saphyr::MarkedYaml::load_from_str(schema_string).unwrap();
450        let first_schema = s_docs.first().unwrap();
451        if let YamlData::Mapping(mapping) = &first_schema.data {
452            let schema = ArraySchema::try_from(mapping).unwrap();
453            let docs = saphyr::MarkedYaml::load_from_str(yaml_string).unwrap();
454            let value = docs.first().unwrap();
455            let context = crate::Context::default();
456            let result = schema.validate(&context, value);
457            assert!(result.is_ok());
458        } else {
459            panic!("Expected first_schema to be a Mapping, but got {first_schema:?}");
460        }
461    }
462
463    #[test]
464    fn array_schema_prefix_items_with_additional_items() {
465        let schema_string = "
466      type: array
467      prefixItems:
468        - type: number
469        - type: string
470        - enum:
471          - Street
472          - Avenue
473          - Boulevard
474        - enum:
475          - NW
476          - NE
477          - SW
478          - SE
479      items:
480        type: string
481";
482
483        let yaml_string = r#"
484        - 1600
485        - Pennsylvania
486        - Avenue
487        - NW
488        - 20500
489        "#;
490
491        let docs = MarkedYaml::load_from_str(schema_string).unwrap();
492        let first_doc = docs.first().unwrap();
493        if let YamlData::Mapping(mapping) = &first_doc.data {
494            let schema: ArraySchema = ArraySchema::try_from(mapping).unwrap();
495            let docs = saphyr::MarkedYaml::load_from_str(yaml_string).unwrap();
496            let value = docs.first().unwrap();
497            let context = crate::Context::default();
498            let result = schema.validate(&context, value);
499            assert!(result.is_ok());
500        } else {
501            panic!("Expected first_doc to be a Mapping, but got {first_doc:?}");
502        }
503    }
504
505    #[test]
506    fn test_contains() {
507        let number_schema = YamlSchema::typed_number(NumberSchema::default());
508        let schema = ArraySchema {
509            contains: Some(number_schema),
510            ..Default::default()
511        };
512        let s = r#"
513        - life
514        - universe
515        - everything
516        - 42
517        "#;
518        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
519        let value = docs.first().unwrap();
520        let context = crate::Context::default();
521        let result = schema.validate(&context, value);
522        assert!(result.is_ok());
523        let errors = context.errors.take();
524        assert!(errors.is_empty());
525    }
526
527    #[test]
528    fn test_min_items_valid() {
529        let schema = ArraySchema {
530            min_items: Some(2),
531            ..Default::default()
532        };
533        let s = "- 1\n- 2\n- 3";
534        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
535        let value = docs.first().unwrap();
536        let context = crate::Context::default();
537        schema.validate(&context, value).unwrap();
538        assert!(!context.has_errors());
539    }
540
541    #[test]
542    fn test_min_items_invalid() {
543        let schema = ArraySchema {
544            min_items: Some(3),
545            ..Default::default()
546        };
547        let s = "- 1\n- 2";
548        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
549        let value = docs.first().unwrap();
550        let context = crate::Context::default();
551        schema.validate(&context, value).unwrap();
552        assert!(context.has_errors());
553    }
554
555    #[test]
556    fn test_max_items_valid() {
557        let schema = ArraySchema {
558            max_items: Some(3),
559            ..Default::default()
560        };
561        let s = "- 1\n- 2";
562        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
563        let value = docs.first().unwrap();
564        let context = crate::Context::default();
565        schema.validate(&context, value).unwrap();
566        assert!(!context.has_errors());
567    }
568
569    #[test]
570    fn test_max_items_invalid() {
571        let schema = ArraySchema {
572            max_items: Some(2),
573            ..Default::default()
574        };
575        let s = "- 1\n- 2\n- 3";
576        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
577        let value = docs.first().unwrap();
578        let context = crate::Context::default();
579        schema.validate(&context, value).unwrap();
580        assert!(context.has_errors());
581    }
582
583    #[test]
584    fn test_min_items_from_yaml() {
585        let schema_string = "type: array\nminItems: 2";
586        let s_docs = saphyr::MarkedYaml::load_from_str(schema_string).unwrap();
587        let first_schema = s_docs.first().unwrap();
588        if let YamlData::Mapping(mapping) = &first_schema.data {
589            let schema = ArraySchema::try_from(mapping).unwrap();
590            assert_eq!(schema.min_items, Some(2));
591        } else {
592            panic!("Expected mapping");
593        }
594    }
595
596    #[test]
597    fn test_max_items_from_yaml() {
598        let schema_string = "type: array\nmaxItems: 5";
599        let s_docs = saphyr::MarkedYaml::load_from_str(schema_string).unwrap();
600        let first_schema = s_docs.first().unwrap();
601        if let YamlData::Mapping(mapping) = &first_schema.data {
602            let schema = ArraySchema::try_from(mapping).unwrap();
603            assert_eq!(schema.max_items, Some(5));
604        } else {
605            panic!("Expected mapping");
606        }
607    }
608
609    #[test]
610    fn test_unique_items_valid() {
611        let schema = ArraySchema {
612            unique_items: Some(true),
613            ..Default::default()
614        };
615        let s = "- 1\n- 2\n- 3";
616        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
617        let value = docs.first().unwrap();
618        let context = crate::Context::default();
619        schema.validate(&context, value).unwrap();
620        assert!(!context.has_errors());
621    }
622
623    #[test]
624    fn test_unique_items_invalid() {
625        let schema = ArraySchema {
626            unique_items: Some(true),
627            ..Default::default()
628        };
629        let s = "- 1\n- 2\n- 1";
630        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
631        let value = docs.first().unwrap();
632        let context = crate::Context::default();
633        schema.validate(&context, value).unwrap();
634        assert!(context.has_errors());
635    }
636
637    #[test]
638    fn test_unique_items_false_allows_duplicates() {
639        let schema = ArraySchema {
640            unique_items: Some(false),
641            ..Default::default()
642        };
643        let s = "- 1\n- 1\n- 2";
644        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
645        let value = docs.first().unwrap();
646        let context = crate::Context::default();
647        schema.validate(&context, value).unwrap();
648        assert!(!context.has_errors());
649    }
650
651    #[test]
652    fn test_unique_items_empty_array() {
653        let schema = ArraySchema {
654            unique_items: Some(true),
655            ..Default::default()
656        };
657        let s = "[]";
658        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
659        let value = docs.first().unwrap();
660        let context = crate::Context::default();
661        schema.validate(&context, value).unwrap();
662        assert!(!context.has_errors());
663    }
664
665    #[test]
666    fn test_unique_items_from_yaml() {
667        let schema_string = "type: array\nuniqueItems: true";
668        let s_docs = saphyr::MarkedYaml::load_from_str(schema_string).unwrap();
669        let first_schema = s_docs.first().unwrap();
670        if let YamlData::Mapping(mapping) = &first_schema.data {
671            let schema = ArraySchema::try_from(mapping).unwrap();
672            assert_eq!(schema.unique_items, Some(true));
673        } else {
674            panic!("Expected mapping");
675        }
676    }
677
678    #[test]
679    fn test_array_schema_contains_fails() {
680        let number_schema = YamlSchema::typed_number(NumberSchema::default());
681        let schema = ArraySchema {
682            contains: Some(number_schema),
683            ..Default::default()
684        };
685        let s = r#"
686        - life
687        - universe
688        - everything
689        "#;
690        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
691        let value = docs.first().unwrap();
692        let context = crate::Context::default();
693        let result = schema.validate(&context, value);
694        assert!(result.is_ok());
695        let errors = context.errors.take();
696        assert!(!errors.is_empty());
697    }
698
699    #[test]
700    fn test_min_contains() {
701        let number_schema = YamlSchema::typed_number(NumberSchema::default());
702        let schema = ArraySchema {
703            contains: Some(number_schema),
704            min_contains: Some(2),
705            ..Default::default()
706        };
707
708        // 2 numbers — passes
709        let s = "- apple\n- 1\n- 2\n";
710        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
711        let context = crate::Context::default();
712        schema.validate(&context, docs.first().unwrap()).unwrap();
713        assert!(context.errors.take().is_empty());
714
715        // only 1 number — fails
716        let s = "- apple\n- 1\n- banana\n";
717        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
718        let context = crate::Context::default();
719        schema.validate(&context, docs.first().unwrap()).unwrap();
720        assert!(!context.errors.take().is_empty());
721    }
722
723    #[test]
724    fn test_max_contains() {
725        let number_schema = YamlSchema::typed_number(NumberSchema::default());
726        let schema = ArraySchema {
727            contains: Some(number_schema),
728            max_contains: Some(2),
729            ..Default::default()
730        };
731
732        // 2 numbers — passes
733        let s = "- 1\n- apple\n- 2\n";
734        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
735        let context = crate::Context::default();
736        schema.validate(&context, docs.first().unwrap()).unwrap();
737        assert!(context.errors.take().is_empty());
738
739        // 3 numbers — fails
740        let s = "- 1\n- 2\n- 3\n";
741        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
742        let context = crate::Context::default();
743        schema.validate(&context, docs.first().unwrap()).unwrap();
744        assert!(!context.errors.take().is_empty());
745    }
746
747    #[test]
748    fn test_min_contains_zero() {
749        let number_schema = YamlSchema::typed_number(NumberSchema::default());
750        let schema = ArraySchema {
751            contains: Some(number_schema),
752            min_contains: Some(0),
753            ..Default::default()
754        };
755
756        // no numbers — still passes because minContains is 0
757        let s = "- apple\n- banana\n";
758        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
759        let context = crate::Context::default();
760        schema.validate(&context, docs.first().unwrap()).unwrap();
761        assert!(context.errors.take().is_empty());
762    }
763}