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#[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 }
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 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 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 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 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 debug!("[ArraySchema] Validating array item {i} with schema: {items}");
241 match items {
242 BooleanOrSchema::Boolean(true) => {
243 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 if let Some(items) = &self.items {
263 match items {
264 BooleanOrSchema::Boolean(true) => { }
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 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 } 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 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 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 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 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 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}