1use crate::loader::{
7 json_schema_reference_budget_message, json_schema_reference_count, parse_frontmatter_scalar,
8 preloaded_json_schema_registry, NoExternalRetrieve, MAX_JSON_SCHEMA_REFERENCES,
9};
10use crate::matcher::{compile_anchored_pattern, compile_glob_pattern};
11use crate::{
12 ByteOffset, Cardinality, Constraint, ConstraintIndex, ConstraintPath, Document,
13 DocumentFrontmatter, FrontmatterAnchor, FrontmatterLocation, FrontmatterPolicy, FrontmatterRef,
14 FrontmatterScalar, FrontmatterSchema, HeaderLevel, Heading, HeadingLocation, Matcher,
15 OutlineProvenance, Proposition, RefAnchor, RuleIndex, RuleOutcome, RuleRef, Schema, SchemaNode,
16 ScopePath, Section, SectionRule, TextRange, UpperBound,
17};
18use std::{error::Error, fmt};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22#[non_exhaustive]
23pub enum DiagnosticId {
24 SkippedLevel,
26 NotAllowed,
28 UnexpectedSection,
30 MissingSection,
32 TooFewSections,
34 TooManySections,
37 MissingTitle,
39 MissingFrontmatter,
41 ForbiddenFrontmatter,
43 InvalidFrontmatter,
45 FrontmatterSchema,
47 OneOf,
49 AnyOf,
51 AtMostOne,
53 AllOrNone,
55 Requires,
57 Conflicts,
59 Ordered,
61}
62
63impl fmt::Display for DiagnosticId {
64 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65 formatter.write_str(self.as_str())
66 }
67}
68
69impl DiagnosticId {
70 pub const fn as_str(self) -> &'static str {
72 match self {
73 Self::SkippedLevel => "skipped-level",
74 Self::NotAllowed => "not-allowed",
75 Self::UnexpectedSection => "unexpected-section",
76 Self::MissingSection => "missing-section",
77 Self::TooFewSections => "too-few-sections",
78 Self::TooManySections => "too-many-sections",
79 Self::MissingTitle => "missing-title",
80 Self::MissingFrontmatter => "missing-frontmatter",
81 Self::ForbiddenFrontmatter => "forbidden-frontmatter",
82 Self::InvalidFrontmatter => "invalid-frontmatter",
83 Self::FrontmatterSchema => "frontmatter-schema",
84 Self::OneOf => "one_of",
85 Self::AnyOf => "any_of",
86 Self::AtMostOne => "at_most_one",
87 Self::AllOrNone => "all_or_none",
88 Self::Requires => "requires",
89 Self::Conflicts => "conflicts",
90 Self::Ordered => "ordered",
91 }
92 }
93}
94
95#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
103#[repr(transparent)]
104pub struct HeaderPath(pub Vec<String>);
105
106impl HeaderPath {
107 pub fn as_slice(&self) -> &[String] {
109 &self.0
110 }
111}
112
113impl fmt::Display for HeaderPath {
114 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
115 for (index, heading) in self.0.iter().enumerate() {
116 if index > 0 {
117 formatter.write_str(" > ")?;
118 }
119 formatter.write_str(heading)?;
120 }
121 Ok(())
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
127pub struct DiagnosticLocation {
128 pub range: TextRange,
130 pub line: u64,
132 pub column: u64,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct InvolvedHeader {
139 pub path: HeaderPath,
141 pub location: DiagnosticLocation,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum DiagnosticReference {
148 Rule {
150 reference: RuleRef,
152 matcher: Matcher,
154 },
155 Frontmatter(FrontmatterRef),
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum DiagnosticTarget {
169 Header(HeaderPath),
171 MissingHeader {
173 parent: HeaderPath,
180 matcher: String,
183 },
184 Document,
189 Frontmatter {
191 block: Option<FrontmatterBlock>,
193 },
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct FrontmatterBlock {
199 pub line_range: FrontmatterLineRange,
201 pub json_pointer: Option<String>,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
207#[non_exhaustive]
208pub struct Diagnostic {
209 pub id: DiagnosticId,
211 pub target: DiagnosticTarget,
214 pub location: DiagnosticLocation,
216 pub schema_node: Option<SchemaNode>,
218 pub involved_headers: Vec<InvolvedHeader>,
220 pub references: Vec<DiagnosticReference>,
222 pub message: String,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
228pub struct FrontmatterLineRange {
229 pub start_line: u64,
231 pub end_line: u64,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct PrepareValidationError {
238 pub message: String,
240}
241
242impl fmt::Display for PrepareValidationError {
243 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
244 formatter.write_str(&self.message)
245 }
246}
247
248impl Error for PrepareValidationError {}
249
250pub struct PreparedValidator {
252 schema: Schema,
253 plan: ValidationPlan,
254}
255
256impl PreparedValidator {
257 pub fn new(schema: &Schema) -> Result<Self, PrepareValidationError> {
270 Ok(Self {
271 schema: schema.clone(),
272 plan: ValidationPlan::new(schema)?,
273 })
274 }
275
276 pub fn validate(&self, document: &Document) -> Vec<Diagnostic> {
287 Validator::new(&self.schema, document).run(&self.plan)
288 }
289}
290
291pub fn validate(
311 schema: &Schema,
312 document: &Document,
313) -> Result<Vec<Diagnostic>, PrepareValidationError> {
314 PreparedValidator::new(schema).map(|prepared| prepared.validate(document))
315}
316
317struct ValidationPlan {
318 outline: Vec<PreparedRule>,
319 frontmatter: Option<jsonschema::Validator>,
320}
321
322impl ValidationPlan {
323 fn new(schema: &Schema) -> Result<Self, PrepareValidationError> {
324 Ok(Self {
325 outline: prepare_rules(&schema.outline, schema.options.match_case)?,
326 frontmatter: frontmatter_schema(&schema.frontmatter)
327 .map(compile_frontmatter_schema)
328 .transpose()?,
329 })
330 }
331}
332
333fn frontmatter_schema(policy: &FrontmatterPolicy) -> Option<&FrontmatterSchema> {
334 match policy {
335 FrontmatterPolicy::Optional { schema }
336 | FrontmatterPolicy::Required { schema }
337 | FrontmatterPolicy::Forbidden { schema } => schema.as_ref(),
338 }
339}
340
341fn compile_frontmatter_schema(
342 schema: &FrontmatterSchema,
343) -> Result<jsonschema::Validator, PrepareValidationError> {
344 let references = std::iter::once(&schema.root)
352 .chain(schema.resources.values())
353 .fold(0usize, |total, document| {
354 total.saturating_add(json_schema_reference_count(document))
355 });
356 if references > MAX_JSON_SCHEMA_REFERENCES {
357 return Err(PrepareValidationError {
358 message: json_schema_reference_budget_message(),
359 });
360 }
361 let mut registry = preloaded_json_schema_registry()
362 .add(schema.root_uri.as_str(), &schema.root)
363 .map_err(|error| PrepareValidationError {
364 message: format!("cannot register frontmatter JSON Schema root: {error}"),
365 })?;
366 for (uri, resource) in &schema.resources {
367 registry =
368 registry
369 .add(uri.as_str(), resource)
370 .map_err(|error| PrepareValidationError {
371 message: format!("cannot register frontmatter JSON Schema resource: {error}"),
372 })?;
373 }
374 let registry = registry.prepare().map_err(|error| PrepareValidationError {
375 message: format!("cannot prepare frontmatter JSON Schema registry: {error}"),
376 })?;
377 jsonschema::draft202012::options()
378 .with_registry(®istry)
379 .with_base_uri(schema.root_uri.clone())
380 .with_retriever(NoExternalRetrieve)
381 .build(&schema.root)
382 .map_err(|error| PrepareValidationError {
383 message: format!("cannot compile frontmatter JSON Schema: {error}"),
384 })
385}
386
387#[derive(Debug)]
388struct PreparedRule {
389 matcher: PreparedMatcher,
390 sections: Vec<PreparedRule>,
391}
392
393fn prepare_rules(
394 rules: &[SectionRule],
395 match_case: bool,
396) -> Result<Vec<PreparedRule>, PrepareValidationError> {
397 rules
398 .iter()
399 .map(|rule| {
400 Ok(PreparedRule {
401 matcher: PreparedMatcher::new(&rule.matcher, match_case)?,
402 sections: prepare_rules(&rule.sections, match_case)?,
403 })
404 })
405 .collect()
406}
407
408#[derive(Debug)]
409enum PreparedMatcher {
410 Exact { text: String, match_case: bool },
411 Pattern(regex::Regex),
412 Any,
413}
414
415impl PreparedMatcher {
416 fn new(matcher: &Matcher, match_case: bool) -> Result<Self, PrepareValidationError> {
417 Ok(match matcher {
418 Matcher::Exact(exact) => Self::Exact {
419 text: exact.0.clone(),
420 match_case,
421 },
422 Matcher::Glob(glob) => Self::Pattern(
423 compile_glob_pattern(&glob.0, match_case).map_err(prepare_matcher_error)?,
424 ),
425 Matcher::Regex(pattern) => {
426 Self::Pattern(compile_pattern(&pattern.0, match_case, false)?)
427 }
428 Matcher::Any => Self::Any,
429 })
430 }
431
432 fn matches(&self, text: &str) -> bool {
433 match self {
434 Self::Exact {
435 text: expected,
436 match_case: true,
437 } => expected == text,
438 Self::Exact {
439 text: expected,
440 match_case: false,
441 } => crate::case_fold::simple_eq(expected, text),
442 Self::Pattern(regex) => regex.is_match(text),
443 Self::Any => true,
444 }
445 }
446}
447
448fn compile_pattern(
449 body: &str,
450 match_case: bool,
451 dot_matches_new_line: bool,
452) -> Result<regex::Regex, PrepareValidationError> {
453 compile_anchored_pattern(body, match_case, dot_matches_new_line).map_err(prepare_matcher_error)
454}
455
456fn prepare_matcher_error(error: regex::Error) -> PrepareValidationError {
457 PrepareValidationError {
458 message: format!("cannot compile matcher: {error}"),
459 }
460}
461
462struct Validator<'a> {
463 schema: &'a Schema,
464 document: &'a Document,
465 diagnostics: Vec<Diagnostic>,
466}
467
468struct BindScopeInput<'a, 'd> {
469 sections: &'a [PathedSection<'d>],
470 rules: &'a [SectionRule],
471 prepared_rules: &'a [PreparedRule],
472 strict: bool,
473 ordered: bool,
474 schema_scope: &'a ScopePath,
475 parent: Option<&'d Heading>,
476 parent_path: &'a HeaderPath,
477}
478
479struct OrderCheck<'a, 'd> {
480 rules: &'a [SectionRule],
481 occurrences: &'a [BoundSection<'d>],
482 schema_scope: &'a ScopePath,
483 parent: Option<&'d Heading>,
484 parent_path: &'a HeaderPath,
485}
486
487struct CardinalityCheck<'a, 'd> {
488 cardinality: Cardinality,
489 count: usize,
490 rule: &'a SectionRule,
491 rule_index: usize,
492 occurrences: &'a [BoundSection<'d>],
493 schema_scope: &'a ScopePath,
494 parent: Option<&'d Heading>,
495 parent_path: &'a HeaderPath,
496}
497
498impl<'a> Validator<'a> {
499 fn new(schema: &'a Schema, document: &'a Document) -> Self {
500 Self {
501 schema,
502 document,
503 diagnostics: Vec::new(),
504 }
505 }
506
507 fn run(mut self, plan: &ValidationPlan) -> Vec<Diagnostic> {
508 self.validate_frontmatter(plan.frontmatter.as_ref());
509 let document = self.document;
510 let top = top_level_sections(&document.sections);
511 let has_h1 = top
512 .iter()
513 .any(|pathed| pathed.section.heading.level == HeaderLevel::H1);
514 let root_level = match self.schema.outline_provenance {
523 OutlineProvenance::Outline => 0,
524 OutlineProvenance::NoTitle => 1,
525 OutlineProvenance::Title | OutlineProvenance::BareSections => u8::from(!has_h1),
526 };
527 if !self.schema.options.allow_skipped_levels {
528 self.validate_skipped_levels(&document.sections, root_level, &HeaderPath::default());
535 }
536 let frontmatter = match &document.frontmatter {
537 DocumentFrontmatter::Mapping { value, .. } => Some(value),
538 DocumentFrontmatter::Absent | DocumentFrontmatter::Invalid { .. } => None,
539 };
540 match self.schema.outline_provenance {
541 OutlineProvenance::Outline => self.validate_outline_root(&top, plan, frontmatter),
542 OutlineProvenance::Title
543 | OutlineProvenance::BareSections
544 | OutlineProvenance::NoTitle => {
545 self.validate_sugar_root(&top, has_h1, plan, frontmatter)
546 }
547 }
548 self.diagnostics
549 }
550
551 fn validate_outline_root(
558 &mut self,
559 top: &[PathedSection<'a>],
560 plan: &ValidationPlan,
561 frontmatter: Option<&'a serde_json::Map<String, serde_json::Value>>,
562 ) {
563 let schema = self.schema;
564 let admitted = admitted_at_root(top, HeaderLevel::H1, schema.options.allow_skipped_levels);
565 let root_scope = ScopePath(Vec::new());
566 let root_path = HeaderPath::default();
567 let root = self.bind_scope(BindScopeInput {
568 sections: &admitted,
569 rules: &schema.outline,
570 prepared_rules: &plan.outline,
571 strict: false,
572 ordered: schema.options.ordered_sections,
573 schema_scope: &root_scope,
574 parent: None,
575 parent_path: &root_path,
576 });
577 self.validate_constraints(
578 EvalCtx {
579 current: &root,
580 current_rules: &schema.outline,
581 root: &root,
582 root_rules: &schema.outline,
583 frontmatter,
584 match_case: schema.options.match_case,
585 },
586 &schema.constraints,
587 &root_scope,
588 None,
589 &root_path,
590 );
591 }
592
593 fn validate_sugar_root(
608 &mut self,
609 top: &[PathedSection<'a>],
610 has_h1: bool,
611 plan: &ValidationPlan,
612 frontmatter: Option<&'a serde_json::Map<String, serde_json::Value>>,
613 ) {
614 let schema = self.schema;
615 let provenance = schema.outline_provenance;
616 let (Some(rule), Some(prepared)) = (schema.outline.first(), plan.outline.first()) else {
617 return;
618 };
619
620 if provenance == OutlineProvenance::NoTitle || !has_h1 {
621 if provenance != OutlineProvenance::NoTitle {
627 self.emit(
628 Diagnostic {
629 id: DiagnosticId::MissingTitle,
630 target: DiagnosticTarget::MissingHeader {
633 parent: HeaderPath::default(),
634 matcher: matcher_label(&rule.matcher),
635 },
636 location: root_location(),
637 schema_node: Some(SchemaNode::Title),
638 involved_headers: Vec::new(),
639 references: Vec::new(),
640 message: "the document has no required title".into(),
641 },
642 None,
643 false,
644 );
645 }
646 if provenance == OutlineProvenance::NoTitle {
647 for pathed in top {
651 if pathed.section.heading.level == HeaderLevel::H1 {
652 self.emit_present(
653 DiagnosticId::NotAllowed,
654 pathed.path.clone(),
655 &pathed.section.heading,
656 Some(SchemaNode::Title),
657 "the schema declares a document with no title",
658 );
659 }
660 }
661 }
662 let admitted =
663 admitted_at_root(top, HeaderLevel::H2, schema.options.allow_skipped_levels);
664 self.bind_sugar_sections(&admitted, rule, prepared, frontmatter, None);
665 return;
666 }
667
668 let admitted = admitted_at_root(top, HeaderLevel::H1, schema.options.allow_skipped_levels);
678 let mut occurrences = Vec::new();
679 let mut admitted_strays = Vec::new();
680 for pathed in &admitted {
681 if pathed.section.heading.level == HeaderLevel::H1 {
682 if !prepared.matcher.matches(&pathed.section.heading.text) {
685 self.emit_present(
686 DiagnosticId::NotAllowed,
687 pathed.path.clone(),
688 &pathed.section.heading,
689 Some(SchemaNode::Title),
690 "the title does not match the schema title matcher",
691 );
692 }
693 occurrences.push(pathed);
694 } else {
695 admitted_strays.push(PathedSection {
700 section: pathed.section,
701 path: pathed.path.clone(),
702 });
703 }
704 }
705 if let Some(excess) = occurrences.get(1) {
710 self.emit(
711 Diagnostic {
712 id: DiagnosticId::TooManySections,
713 target: DiagnosticTarget::Header(excess.path.clone()),
714 location: heading_location(&excess.section.heading.location),
715 schema_node: Some(SchemaNode::Title),
716 involved_headers: Vec::new(),
717 references: Vec::new(),
718 message: "the document has more than one title".to_owned(),
719 },
720 Some(&excess.section.heading),
721 true,
722 );
723 }
724 let attribute = occurrences.len() > 1;
733 for (index, occurrence) in occurrences.iter().enumerate() {
734 let mut children = child_sections(occurrence.section, &occurrence.path);
735 if index == 0 && !admitted_strays.is_empty() {
736 let mut merged = std::mem::take(&mut admitted_strays);
737 merged.extend(children);
738 children = merged;
739 }
740 let owner = attribute.then_some((&occurrence.section.heading, &occurrence.path));
741 self.bind_sugar_sections(&children, rule, prepared, frontmatter, owner);
742 }
743 }
744
745 fn bind_sugar_sections(
757 &mut self,
758 sections: &[PathedSection<'a>],
759 rule: &'a SectionRule,
760 prepared: &PreparedRule,
761 frontmatter: Option<&'a serde_json::Map<String, serde_json::Value>>,
762 owner: Option<(&'a Heading, &HeaderPath)>,
763 ) {
764 let scope = ScopePath(Vec::new());
765 let (parent, path) = match owner {
766 Some((heading, path)) => (Some(heading), path.clone()),
767 None => (None, HeaderPath::default()),
768 };
769 let bound = self.bind_scope(BindScopeInput {
770 sections,
771 rules: &rule.sections,
772 prepared_rules: &prepared.sections,
773 strict: rule.strict,
774 ordered: rule.ordered,
775 schema_scope: &scope,
776 parent,
777 parent_path: &path,
778 });
779 self.validate_constraints(
780 EvalCtx {
781 current: &bound,
782 current_rules: &rule.sections,
783 root: &bound,
786 root_rules: &rule.sections,
787 frontmatter,
788 match_case: self.schema.options.match_case,
789 },
790 &rule.constraints,
791 &scope,
792 parent,
793 &path,
794 );
795 }
796
797 fn validate_frontmatter(&mut self, validator: Option<&jsonschema::Validator>) {
798 let required = matches!(self.schema.frontmatter, FrontmatterPolicy::Required { .. });
799 let forbidden = matches!(self.schema.frontmatter, FrontmatterPolicy::Forbidden { .. });
800 match &self.document.frontmatter {
801 DocumentFrontmatter::Absent => {
802 if required {
803 self.emit_frontmatter(
804 DiagnosticId::MissingFrontmatter,
805 None,
806 "the document is missing required frontmatter".into(),
807 None,
808 );
809 }
810 }
811 DocumentFrontmatter::Invalid { location, message } => {
812 if forbidden {
813 self.emit_frontmatter(
814 DiagnosticId::ForbiddenFrontmatter,
815 Some(*location),
816 "frontmatter is forbidden by the schema".into(),
817 None,
818 );
819 }
820 self.emit_frontmatter(
821 DiagnosticId::InvalidFrontmatter,
822 Some(*location),
823 message.clone(),
824 None,
825 );
826 }
827 DocumentFrontmatter::Mapping {
828 value,
829 location,
830 anchors,
831 } => {
832 if forbidden {
833 self.emit_frontmatter(
834 DiagnosticId::ForbiddenFrontmatter,
835 Some(*location),
836 "frontmatter is forbidden by the schema".into(),
837 None,
838 );
839 }
840 let Some(validator) = validator else {
841 return;
842 };
843 let instance = serde_json::Value::Object(value.clone());
846 let mut errors = validator
847 .iter_errors(&instance)
848 .map(|error| (error.instance_path().as_str().to_owned(), error.to_string()))
849 .collect::<Vec<_>>();
850 errors.sort();
851 for (pointer, message) in errors {
852 let anchor = anchors.get(&pointer);
855 self.emit_frontmatter_at(
856 DiagnosticId::FrontmatterSchema,
857 Some(*location),
858 anchor,
859 message,
860 Some(pointer),
861 );
862 }
863 }
864 }
865 }
866
867 fn emit_frontmatter(
869 &mut self,
870 id: DiagnosticId,
871 location: Option<FrontmatterLocation>,
872 message: String,
873 json_pointer: Option<String>,
874 ) {
875 self.emit_frontmatter_at(id, location, None, message, json_pointer);
876 }
877
878 fn emit_frontmatter_at(
883 &mut self,
884 id: DiagnosticId,
885 location: Option<FrontmatterLocation>,
886 anchor: Option<FrontmatterAnchor>,
887 message: String,
888 json_pointer: Option<String>,
889 ) {
890 let diagnostic_location =
891 location.map_or_else(root_location, |location| DiagnosticLocation {
892 range: location.range,
893 line: anchor.map_or(location.start_line, |anchor| anchor.line),
894 column: anchor.map_or(1, |anchor| anchor.column),
895 });
896 let block = location.map(|location| FrontmatterBlock {
897 line_range: FrontmatterLineRange {
898 start_line: location.start_line,
899 end_line: location.end_line,
900 },
901 json_pointer,
902 });
903 let schema_node = if id == DiagnosticId::FrontmatterSchema {
904 Some(SchemaNode::FrontmatterSchemaDocument)
905 } else {
906 Some(SchemaNode::Frontmatter)
907 };
908 self.emit(
909 Diagnostic {
910 id,
911 target: DiagnosticTarget::Frontmatter { block },
912 location: diagnostic_location,
913 schema_node,
914 involved_headers: Vec::new(),
915 references: Vec::new(),
916 message,
917 },
918 None,
919 false,
920 );
921 }
922
923 fn validate_skipped_levels(
934 &mut self,
935 sections: &[Section],
936 parent_level: u8,
937 parent_path: &HeaderPath,
938 ) {
939 for section in sections {
940 let path = appended_path(parent_path, §ion.heading.diagnostic_text);
941 if section.heading.level as u8 > parent_level + 1 {
942 self.emit(
943 Diagnostic {
944 id: DiagnosticId::SkippedLevel,
945 target: DiagnosticTarget::Header(path.clone()),
946 location: heading_location(§ion.heading.location),
947 schema_node: None,
948 involved_headers: Vec::new(),
949 references: Vec::new(),
950 message: "the heading skips a level below its parent".into(),
951 },
952 Some(§ion.heading),
953 true,
954 );
955 }
956 self.validate_skipped_levels(§ion.children, section.heading.level as u8, &path);
957 }
958 }
959
960 fn bind_scope<'d>(&mut self, input: BindScopeInput<'_, 'd>) -> BoundScope<'d> {
961 let BindScopeInput {
962 sections,
963 rules,
964 prepared_rules,
965 strict,
966 ordered,
967 schema_scope,
968 parent,
969 parent_path,
970 } = input;
971 let mut counts = vec![0_usize; rules.len()];
972 let mut occurrences = Vec::new();
973 for pathed in sections {
974 let section = pathed.section;
975 let path = pathed.path.clone();
979 let matched = rules
980 .iter()
981 .zip(prepared_rules)
982 .enumerate()
983 .find(|(_, (_, prepared))| prepared.matcher.matches(§ion.heading.text));
984 let Some((rule_index, (rule, prepared_rule))) = matched else {
985 if strict {
986 let schema_node = schema_scope.0.split_last().map(|(index, parent_scope)| {
987 SchemaNode::Rule(crate::RulePath {
988 scope: ScopePath(parent_scope.to_vec()),
989 index: *index,
990 })
991 });
992 self.emit_present(
993 DiagnosticId::UnexpectedSection,
994 path,
995 §ion.heading,
996 schema_node,
997 "the section is not permitted in this closed scope",
998 );
999 }
1000 continue;
1001 };
1002 let node = SchemaNode::Rule(rule_path(schema_scope, rule_index));
1003 if matches!(rule.outcome, RuleOutcome::Deny) {
1004 self.emit_present(
1005 DiagnosticId::NotAllowed,
1006 path,
1007 §ion.heading,
1008 Some(node),
1009 "the first matching rule denies this section",
1010 );
1011 continue;
1012 }
1013
1014 if let Some(count) = counts.get_mut(rule_index) {
1015 *count += 1;
1016 }
1017 let child_refs = child_sections(section, &path);
1018 let mut child_scope_path = schema_scope.clone();
1019 child_scope_path.0.push(RuleIndex(rule_index));
1020 let child = self.bind_scope(BindScopeInput {
1021 sections: &child_refs,
1022 rules: &rule.sections,
1023 prepared_rules: &prepared_rule.sections,
1024 strict: rule.strict,
1025 ordered: rule.ordered,
1026 schema_scope: &child_scope_path,
1027 parent: Some(§ion.heading),
1028 parent_path: &path,
1029 });
1030 occurrences.push(BoundSection {
1031 rule_index,
1032 section,
1033 path,
1034 child,
1035 });
1036 }
1037
1038 for (rule_index, rule) in rules.iter().enumerate() {
1039 let RuleOutcome::Allow(cardinality) = rule.outcome else {
1040 continue;
1041 };
1042 let count = counts.get(rule_index).copied().unwrap_or_default();
1043 self.validate_cardinality(CardinalityCheck {
1044 cardinality,
1045 count,
1046 rule,
1047 rule_index,
1048 occurrences: &occurrences,
1049 schema_scope,
1050 parent,
1051 parent_path,
1052 });
1053 }
1054 if ordered {
1055 self.validate_order(OrderCheck {
1056 rules,
1057 occurrences: &occurrences,
1058 schema_scope,
1059 parent,
1060 parent_path,
1061 });
1062 }
1063 BoundScope { occurrences }
1064 }
1065
1066 fn validate_order(&mut self, check: OrderCheck<'_, '_>) {
1077 let OrderCheck {
1078 rules,
1079 occurrences,
1080 schema_scope,
1081 parent,
1082 parent_path,
1083 } = check;
1084 let present = rules
1085 .iter()
1086 .enumerate()
1087 .filter(|(_, rule)| matches!(rule.outcome, RuleOutcome::Allow(_)))
1088 .map(|(rule_index, rule)| {
1089 let matched = occurrences
1090 .iter()
1091 .filter(|occurrence| occurrence.rule_index == rule_index)
1092 .collect::<Vec<_>>();
1093 (rule, matched)
1094 })
1095 .filter(|(_, matched)| !matched.is_empty())
1096 .collect::<Vec<_>>();
1097 let schema_node = schema_scope.0.split_last().map_or_else(
1098 || {
1099 (self.schema.outline_provenance != OutlineProvenance::Outline)
1100 .then_some(SchemaNode::Title)
1101 },
1102 |(index, parent_scope)| {
1103 Some(SchemaNode::Rule(crate::RulePath {
1104 scope: ScopePath(parent_scope.to_vec()),
1105 index: *index,
1106 }))
1107 },
1108 );
1109 for pair in present.windows(2) {
1110 let [(earlier, earlier_matched), (later, later_matched)] = pair else {
1111 continue;
1112 };
1113 let position =
1114 |occurrence: &&BoundSection<'_>| occurrence.section.heading.location.range.start.0;
1115 let last_earlier = earlier_matched.iter().map(position).max();
1116 let first_later = later_matched.iter().map(position).min();
1117 if matches!((last_earlier, first_later), (Some(last), Some(first)) if last < first) {
1118 continue;
1119 }
1120 let mut involved = earlier_matched
1121 .iter()
1122 .chain(later_matched.iter())
1123 .map(|occurrence| InvolvedHeader {
1124 path: occurrence.path.clone(),
1125 location: heading_location(&occurrence.section.heading.location),
1126 })
1127 .collect::<Vec<_>>();
1128 involved.sort_by_key(|header| (header.location.line, header.location.column));
1129 self.emit(
1130 Diagnostic {
1131 id: DiagnosticId::Ordered,
1132 target: match parent {
1133 Some(_) => DiagnosticTarget::Header(parent_path.clone()),
1134 None => DiagnosticTarget::Document,
1135 },
1136 location: parent
1137 .map_or_else(root_location, |heading| heading_location(&heading.location)),
1138 schema_node: schema_node.clone(),
1139 involved_headers: involved,
1140 references: Vec::new(),
1141 message: format!(
1142 "sections are out of the declared order: `{}` must precede `{}`",
1143 matcher_label(&earlier.matcher),
1144 matcher_label(&later.matcher)
1145 ),
1146 },
1147 parent,
1148 true,
1149 );
1150 }
1151 }
1152
1153 fn validate_cardinality(&mut self, check: CardinalityCheck<'_, '_>) {
1154 let CardinalityCheck {
1155 cardinality,
1156 count,
1157 rule,
1158 rule_index,
1159 occurrences,
1160 schema_scope,
1161 parent,
1162 parent_path,
1163 } = check;
1164 let schema_node = Some(SchemaNode::Rule(rule_path(schema_scope, rule_index)));
1165 if count < cardinality.min as usize {
1166 let id = if count == 0 {
1167 DiagnosticId::MissingSection
1168 } else {
1169 DiagnosticId::TooFewSections
1170 };
1171 self.emit(
1172 Diagnostic {
1173 id,
1174 target: DiagnosticTarget::MissingHeader {
1178 parent: parent_path.clone(),
1179 matcher: matcher_label(&rule.matcher),
1180 },
1181 location: parent
1182 .map_or_else(root_location, |heading| heading_location(&heading.location)),
1183 schema_node: schema_node.clone(),
1184 involved_headers: Vec::new(),
1185 references: Vec::new(),
1186 message: format!(
1187 "matched {count} sections, but at least {} are required",
1188 cardinality.min
1189 ),
1190 },
1191 None,
1192 false,
1193 );
1194 }
1195 let UpperBound::Bounded(max) = cardinality.max else {
1196 return;
1197 };
1198 if count <= max as usize {
1199 return;
1200 }
1201 let excess_index = max as usize;
1202 let Some(excess) = occurrences
1203 .iter()
1204 .filter(|occurrence| occurrence.rule_index == rule_index)
1205 .nth(excess_index)
1206 else {
1207 return;
1208 };
1209 self.emit(
1210 Diagnostic {
1211 id: DiagnosticId::TooManySections,
1212 target: DiagnosticTarget::Header(excess.path.clone()),
1213 location: heading_location(&excess.section.heading.location),
1214 schema_node,
1215 involved_headers: Vec::new(),
1216 references: Vec::new(),
1217 message: format!("more than {max} sections match this rule"),
1218 },
1219 Some(&excess.section.heading),
1220 true,
1221 );
1222 }
1223
1224 fn validate_constraints<'d>(
1225 &mut self,
1226 eval: EvalCtx<'_, 'd>,
1227 constraints: &[Constraint],
1228 schema_scope: &ScopePath,
1229 parent: Option<&Heading>,
1230 parent_path: &HeaderPath,
1231 ) {
1232 for (index, constraint) in constraints.iter().enumerate() {
1233 if eval.constraint_satisfied(constraint) {
1234 continue;
1235 }
1236 let id = constraint_id(constraint);
1237 let involved = eval
1238 .constraint_occurrences(constraint)
1239 .into_iter()
1240 .map(|occurrence| InvolvedHeader {
1241 path: occurrence.path.clone(),
1242 location: heading_location(&occurrence.section.heading.location),
1243 })
1244 .collect();
1245 self.emit(
1246 Diagnostic {
1247 id,
1248 target: match parent {
1253 Some(_) => DiagnosticTarget::Header(parent_path.clone()),
1254 None => DiagnosticTarget::Document,
1255 },
1256 location: parent
1257 .map_or_else(root_location, |heading| heading_location(&heading.location)),
1258 schema_node: Some(SchemaNode::Constraint(ConstraintPath {
1259 scope: schema_scope.clone(),
1260 index: ConstraintIndex(index),
1261 })),
1262 involved_headers: involved,
1263 references: eval.constraint_references(constraint),
1264 message: format!("the `{}` constraint is not satisfied", id.as_str()),
1265 },
1266 parent,
1267 true,
1268 );
1269 }
1270
1271 for occurrence in &eval.current.occurrences {
1272 let Some(rule) = eval.current_rules.get(occurrence.rule_index) else {
1273 continue;
1274 };
1275 let mut child_schema_scope = schema_scope.clone();
1276 child_schema_scope.0.push(RuleIndex(occurrence.rule_index));
1277 self.validate_constraints(
1278 EvalCtx {
1279 current: &occurrence.child,
1280 current_rules: &rule.sections,
1281 root: eval.root,
1282 root_rules: eval.root_rules,
1283 frontmatter: eval.frontmatter,
1284 match_case: eval.match_case,
1285 },
1286 &rule.constraints,
1287 &child_schema_scope,
1288 Some(&occurrence.section.heading),
1289 &occurrence.path,
1290 );
1291 }
1292 }
1293
1294 fn emit_present(
1296 &mut self,
1297 id: DiagnosticId,
1298 path: HeaderPath,
1299 heading: &Heading,
1300 schema_node: Option<SchemaNode>,
1301 message: &str,
1302 ) {
1303 self.emit(
1304 Diagnostic {
1305 id,
1306 target: DiagnosticTarget::Header(path),
1307 location: heading_location(&heading.location),
1308 schema_node,
1309 involved_headers: Vec::new(),
1310 references: Vec::new(),
1311 message: message.into(),
1312 },
1313 Some(heading),
1314 true,
1315 );
1316 }
1317
1318 fn emit(&mut self, diagnostic: Diagnostic, anchor: Option<&Heading>, inline_allowed: bool) {
1319 let id = diagnostic.id.as_str();
1320 if self.document.file_suppressions.contains(id)
1321 || (inline_allowed && anchor.is_some_and(|heading| heading.suppressions.contains(id)))
1322 {
1323 return;
1324 }
1325 self.diagnostics.push(diagnostic);
1326 }
1327}
1328
1329#[derive(Debug)]
1330struct BoundScope<'d> {
1331 occurrences: Vec<BoundSection<'d>>,
1332}
1333
1334#[derive(Debug)]
1335struct BoundSection<'d> {
1336 rule_index: usize,
1337 section: &'d Section,
1338 path: HeaderPath,
1339 child: BoundScope<'d>,
1340}
1341
1342#[derive(Debug)]
1344struct PathedSection<'d> {
1345 section: &'d Section,
1346 path: HeaderPath,
1347}
1348
1349fn top_level_sections(sections: &[Section]) -> Vec<PathedSection<'_>> {
1352 sections
1353 .iter()
1354 .map(|section| PathedSection {
1355 section,
1356 path: appended_path(&HeaderPath::default(), §ion.heading.diagnostic_text),
1357 })
1358 .collect()
1359}
1360
1361fn admitted_at_root<'d>(
1373 top: &[PathedSection<'d>],
1374 child_level: HeaderLevel,
1375 allow_skipped: bool,
1376) -> Vec<PathedSection<'d>> {
1377 top.iter()
1378 .filter(|pathed| {
1379 let level = pathed.section.heading.level;
1380 level == child_level || (allow_skipped && level > child_level)
1381 })
1382 .map(|pathed| PathedSection {
1383 section: pathed.section,
1384 path: pathed.path.clone(),
1385 })
1386 .collect()
1387}
1388
1389fn child_sections<'d>(section: &'d Section, path: &HeaderPath) -> Vec<PathedSection<'d>> {
1390 section
1391 .children
1392 .iter()
1393 .map(|child| PathedSection {
1394 section: child,
1395 path: appended_path(path, &child.heading.diagnostic_text),
1396 })
1397 .collect()
1398}
1399
1400fn root_location() -> DiagnosticLocation {
1401 DiagnosticLocation {
1402 range: TextRange {
1403 start: ByteOffset(0),
1404 end: ByteOffset(0),
1405 },
1406 line: 1,
1407 column: 1,
1408 }
1409}
1410
1411fn heading_location(location: &HeadingLocation) -> DiagnosticLocation {
1412 DiagnosticLocation {
1413 range: location.line_range,
1414 line: location.line,
1415 column: location.column,
1416 }
1417}
1418
1419fn appended_path(parent: &HeaderPath, child: &str) -> HeaderPath {
1420 let mut path = parent.0.clone();
1421 path.push(child.to_owned());
1422 HeaderPath(path)
1423}
1424
1425fn rule_path(scope: &ScopePath, index: usize) -> crate::RulePath {
1426 crate::RulePath {
1427 scope: scope.clone(),
1428 index: RuleIndex(index),
1429 }
1430}
1431
1432fn matcher_label(matcher: &Matcher) -> String {
1433 match matcher {
1434 Matcher::Exact(text) => text.0.clone(),
1435 Matcher::Glob(pattern) => pattern.0.clone(),
1436 Matcher::Regex(pattern) => format!("/{}/", pattern.0),
1437 Matcher::Any => "*".into(),
1438 }
1439}
1440
1441fn constraint_id(constraint: &Constraint) -> DiagnosticId {
1442 match constraint {
1443 Constraint::OneOf(_) => DiagnosticId::OneOf,
1444 Constraint::AnyOf(_) => DiagnosticId::AnyOf,
1445 Constraint::AtMostOne(_) => DiagnosticId::AtMostOne,
1446 Constraint::AllOrNone(_) => DiagnosticId::AllOrNone,
1447 Constraint::Requires { .. } => DiagnosticId::Requires,
1448 Constraint::Conflicts { .. } => DiagnosticId::Conflicts,
1449 Constraint::Ordered(_) => DiagnosticId::Ordered,
1450 }
1451}
1452
1453fn frontmatter_satisfied(
1459 frontmatter: Option<&serde_json::Map<String, serde_json::Value>>,
1460 reference: &FrontmatterRef,
1461 match_case: bool,
1462) -> bool {
1463 let Some(value) = frontmatter.and_then(|mapping| mapping.get(&reference.path.first.0)) else {
1464 return false;
1465 };
1466 let mut value = value;
1467 for key in &reference.path.rest {
1468 let Some(next) = value.as_object().and_then(|mapping| mapping.get(&key.0)) else {
1469 return false;
1470 };
1471 value = next;
1472 }
1473 if value.is_null() {
1474 return false;
1475 }
1476 match &reference.equals {
1477 None => true,
1478 Some(expected) => frontmatter_scalar_equals(value, expected, match_case),
1479 }
1480}
1481
1482fn frontmatter_scalar_equals(
1491 value: &serde_json::Value,
1492 expected: &FrontmatterScalar,
1493 match_case: bool,
1494) -> bool {
1495 match (value, expected) {
1496 (serde_json::Value::Bool(actual), FrontmatterScalar::Boolean(expected)) => {
1497 actual == expected
1498 }
1499 (serde_json::Value::String(actual), FrontmatterScalar::String(expected)) => {
1500 if match_case {
1501 actual == expected
1502 } else {
1503 crate::case_fold::simple_eq(actual, expected)
1504 }
1505 }
1506 (
1507 serde_json::Value::Number(actual),
1508 FrontmatterScalar::Integer(_) | FrontmatterScalar::Float(_),
1509 ) => parse_frontmatter_scalar(&actual.to_string()) == *expected,
1510 _ => false,
1513 }
1514}
1515
1516#[derive(Clone, Copy)]
1517struct EvalCtx<'s, 'd> {
1518 current: &'s BoundScope<'d>,
1519 current_rules: &'s [SectionRule],
1520 root: &'s BoundScope<'d>,
1521 root_rules: &'s [SectionRule],
1522 frontmatter: Option<&'d serde_json::Map<String, serde_json::Value>>,
1526 match_case: bool,
1527}
1528
1529impl<'s, 'd> EvalCtx<'s, 'd> {
1530 fn constraint_satisfied(self, constraint: &Constraint) -> bool {
1531 match constraint {
1532 Constraint::OneOf(refs) => {
1533 refs.iter()
1534 .filter(|proposition| self.proposition_satisfied(proposition))
1535 .count()
1536 == 1
1537 }
1538 Constraint::AnyOf(refs) => refs
1539 .iter()
1540 .any(|proposition| self.proposition_satisfied(proposition)),
1541 Constraint::AtMostOne(refs) => {
1542 refs.iter()
1543 .filter(|proposition| self.proposition_satisfied(proposition))
1544 .count()
1545 <= 1
1546 }
1547 Constraint::AllOrNone(refs) => {
1548 let values = refs
1549 .iter()
1550 .map(|proposition| self.proposition_satisfied(proposition))
1551 .collect::<Vec<_>>();
1552 values.iter().all(|value| *value) || values.iter().all(|value| !*value)
1553 }
1554 Constraint::Requires {
1555 condition,
1556 consequences,
1557 } => {
1558 !self.proposition_satisfied(condition)
1559 || consequences
1560 .iter()
1561 .all(|proposition| self.proposition_satisfied(proposition))
1562 }
1563 Constraint::Conflicts {
1564 condition,
1565 exclusions,
1566 } => {
1567 !self.proposition_satisfied(condition)
1568 || exclusions
1569 .iter()
1570 .all(|proposition| !self.proposition_satisfied(proposition))
1571 }
1572 Constraint::Ordered(refs) => {
1573 let satisfied = refs
1574 .iter()
1575 .map(|reference| self.resolve_occurrences(reference))
1576 .filter(|occurrences| !occurrences.is_empty())
1577 .collect::<Vec<_>>();
1578 satisfied
1579 .iter()
1580 .zip(satisfied.iter().skip(1))
1581 .all(|(left, right)| {
1582 let last_left = left
1583 .iter()
1584 .map(|occurrence| occurrence.section.heading.location.range.start.0)
1585 .max();
1586 let first_right = right
1587 .iter()
1588 .map(|occurrence| occurrence.section.heading.location.range.start.0)
1589 .min();
1590 matches!((last_left, first_right), (Some(left), Some(right)) if left < right)
1591 })
1592 }
1593 }
1594 }
1595
1596 fn proposition_satisfied(self, proposition: &Proposition) -> bool {
1597 match proposition {
1598 Proposition::Rule(reference) => !self.resolve_occurrences(reference).is_empty(),
1599 Proposition::Frontmatter(reference) => {
1600 frontmatter_satisfied(self.frontmatter, reference, self.match_case)
1601 }
1602 }
1603 }
1604
1605 fn resolve_occurrences(self, reference: &RuleRef) -> Vec<&'s BoundSection<'d>> {
1606 let (start_scope, start_rules) = match reference.anchor {
1607 RefAnchor::CurrentScope => (self.current, self.current_rules),
1608 RefAnchor::SchemaRoot => (self.root, self.root_rules),
1609 };
1610 let mut candidate_scopes = vec![(start_scope, start_rules)];
1611 let mut found = Vec::new();
1612 for (position, id) in reference.path.iter().enumerate() {
1613 found.clear();
1614 let mut next_scopes = Vec::new();
1615 for (candidate, candidate_rules) in std::mem::take(&mut candidate_scopes) {
1616 let Some((index, rule)) = candidate_rules
1617 .iter()
1618 .enumerate()
1619 .find(|(_, rule)| rule.id.as_ref() == Some(id))
1620 else {
1621 continue;
1622 };
1623 for occurrence in candidate
1624 .occurrences
1625 .iter()
1626 .filter(|occurrence| occurrence.rule_index == index)
1627 {
1628 found.push(occurrence);
1629 next_scopes.push((&occurrence.child, &rule.sections[..]));
1630 }
1631 }
1632 if position < reference.path.rest.len() {
1633 candidate_scopes = next_scopes;
1634 }
1635 }
1636 found
1637 }
1638
1639 fn constraint_occurrences(self, constraint: &Constraint) -> Vec<&'s BoundSection<'d>> {
1640 let mut occurrences = Vec::new();
1641 match constraint {
1642 Constraint::OneOf(refs)
1643 | Constraint::AnyOf(refs)
1644 | Constraint::AtMostOne(refs)
1645 | Constraint::AllOrNone(refs) => {
1646 for proposition in refs.iter() {
1647 self.add_proposition_occurrences(proposition, &mut occurrences);
1648 }
1649 }
1650 Constraint::Requires {
1651 condition,
1652 consequences,
1653 } => {
1654 self.add_proposition_occurrences(condition, &mut occurrences);
1655 for proposition in consequences.iter() {
1656 self.add_proposition_occurrences(proposition, &mut occurrences);
1657 }
1658 }
1659 Constraint::Conflicts {
1660 condition,
1661 exclusions,
1662 } => {
1663 self.add_proposition_occurrences(condition, &mut occurrences);
1664 for proposition in exclusions.iter() {
1665 self.add_proposition_occurrences(proposition, &mut occurrences);
1666 }
1667 }
1668 Constraint::Ordered(refs) => {
1669 for reference in refs.iter() {
1670 occurrences.extend(self.resolve_occurrences(reference));
1671 }
1672 }
1673 }
1674 occurrences.sort_by_key(|occurrence| occurrence.section.heading.location.range.start.0);
1675 occurrences.dedup_by_key(|occurrence| occurrence.section.heading.location.range.start.0);
1676 occurrences
1677 }
1678
1679 fn constraint_references(self, constraint: &Constraint) -> Vec<DiagnosticReference> {
1680 let mut references = Vec::new();
1681 match constraint {
1682 Constraint::OneOf(items)
1683 | Constraint::AnyOf(items)
1684 | Constraint::AtMostOne(items)
1685 | Constraint::AllOrNone(items) => {
1686 references.extend(
1687 items
1688 .iter()
1689 .filter_map(|proposition| self.diagnostic_reference(proposition)),
1690 );
1691 }
1692 Constraint::Requires {
1693 condition,
1694 consequences,
1695 } => {
1696 references.extend(
1697 std::iter::once(condition)
1698 .chain(consequences.iter())
1699 .filter_map(|proposition| self.diagnostic_reference(proposition)),
1700 );
1701 }
1702 Constraint::Conflicts {
1703 condition,
1704 exclusions,
1705 } => {
1706 references.extend(
1707 std::iter::once(condition)
1708 .chain(exclusions.iter())
1709 .filter_map(|proposition| self.diagnostic_reference(proposition)),
1710 );
1711 }
1712 Constraint::Ordered(items) => {
1713 references.extend(items.iter().filter_map(|reference| {
1714 self.rule_for_ref(reference)
1715 .map(|rule| DiagnosticReference::Rule {
1716 reference: reference.clone(),
1717 matcher: rule.matcher.clone(),
1718 })
1719 }));
1720 }
1721 }
1722 references
1723 }
1724
1725 fn diagnostic_reference(self, proposition: &Proposition) -> Option<DiagnosticReference> {
1726 match proposition {
1727 Proposition::Rule(reference) => {
1728 self.rule_for_ref(reference)
1729 .map(|rule| DiagnosticReference::Rule {
1730 reference: reference.clone(),
1731 matcher: rule.matcher.clone(),
1732 })
1733 }
1734 Proposition::Frontmatter(reference) => {
1735 Some(DiagnosticReference::Frontmatter(reference.clone()))
1736 }
1737 }
1738 }
1739
1740 fn rule_for_ref(self, reference: &RuleRef) -> Option<&'s SectionRule> {
1741 let mut rules = match reference.anchor {
1742 RefAnchor::CurrentScope => self.current_rules,
1743 RefAnchor::SchemaRoot => self.root_rules,
1744 };
1745 let mut target = None;
1746 for id in reference.path.iter() {
1747 target = rules.iter().find(|rule| rule.id.as_ref() == Some(id));
1748 rules = &target?.sections;
1749 }
1750 target
1751 }
1752
1753 fn add_proposition_occurrences(
1754 self,
1755 proposition: &Proposition,
1756 output: &mut Vec<&'s BoundSection<'d>>,
1757 ) {
1758 if let Proposition::Rule(reference) = proposition {
1759 output.extend(self.resolve_occurrences(reference));
1760 }
1761 }
1762}
1763
1764#[cfg(test)]
1765mod tests {
1766 use super::*;
1767 use crate::{
1768 load_schema, parse_markdown, ExactText, GlobPattern, MarkdownOptions, RegexPattern,
1769 };
1770
1771 fn matcher_matches(matcher: &Matcher, text: &str, match_case: bool) -> bool {
1772 PreparedMatcher::new(matcher, match_case)
1773 .expect("test matcher compiles")
1774 .matches(text)
1775 }
1776
1777 #[test]
1778 fn every_matcher_form_is_fully_anchored() {
1779 assert!(matcher_matches(
1780 &Matcher::Exact(ExactText("cat".into())),
1781 "cat",
1782 true
1783 ));
1784 assert!(!matcher_matches(
1785 &Matcher::Exact(ExactText("cat".into())),
1786 "cats",
1787 true
1788 ));
1789 assert!(matcher_matches(
1790 &Matcher::Glob(GlobPattern("c*t".into())),
1791 "coat",
1792 true
1793 ));
1794 assert!(!matcher_matches(
1795 &Matcher::Glob(GlobPattern("c*t".into())),
1796 "a coat",
1797 true
1798 ));
1799 assert!(matcher_matches(
1800 &Matcher::Regex(RegexPattern("c.+t".into())),
1801 "coat",
1802 true
1803 ));
1804 assert!(!matcher_matches(
1805 &Matcher::Regex(RegexPattern("c.+t".into())),
1806 "a coat",
1807 true
1808 ));
1809 }
1810
1811 #[test]
1812 fn glob_treats_every_non_star_character_literally() {
1813 let matcher = Matcher::Glob(GlobPattern("file[1].*".into()));
1814 assert!(matcher_matches(&matcher, "file[1].md", true));
1815 assert!(!matcher_matches(&matcher, "file1.md", true));
1816 }
1817
1818 #[test]
1819 fn glob_star_matches_newlines_in_multiline_setext_text() {
1820 let matcher = Matcher::Glob(GlobPattern("first*last".into()));
1821 assert!(matcher_matches(&matcher, "first\nmiddle\nlast", true));
1822 }
1823
1824 #[test]
1825 fn exact_matching_does_not_compile_input_as_a_regex() {
1826 let text = "x".repeat(1_000_000);
1827 let matcher = Matcher::Exact(ExactText(text.clone()));
1828 assert!(matcher_matches(&matcher, &text, true));
1829 }
1830
1831 #[test]
1832 fn case_insensitive_matching_is_unicode_aware_for_all_forms() {
1833 let matchers = [
1834 Matcher::Exact(ExactText("ÉCOLE".into())),
1835 Matcher::Glob(GlobPattern("ÉCO*".into())),
1836 Matcher::Regex(RegexPattern("ÉCO.*".into())),
1837 ];
1838 for matcher in matchers {
1839 assert!(matcher_matches(&matcher, "école", false));
1840 assert!(!matcher_matches(&matcher, "école", true));
1841 }
1842 let simple_fold_matchers = [
1843 Matcher::Exact(ExactText("S".into())),
1844 Matcher::Glob(GlobPattern("S*".into())),
1845 Matcher::Regex(RegexPattern("S.*".into())),
1846 ];
1847 for matcher in simple_fold_matchers {
1848 assert!(matcher_matches(&matcher, "ſ", false));
1849 }
1850
1851 let full_only_fold_matchers = [
1852 Matcher::Exact(ExactText("Straße".into())),
1853 Matcher::Glob(GlobPattern("Straße*".into())),
1854 Matcher::Regex(RegexPattern("Straße.*".into())),
1855 ];
1856 for matcher in full_only_fold_matchers {
1857 assert!(!matcher_matches(&matcher, "STRASSE", false));
1858 }
1859 }
1860
1861 #[test]
1862 fn inline_regex_flags_compose_with_match_case() {
1863 let matcher = Matcher::Regex(RegexPattern("(?i:api)".into()));
1864 assert!(matcher_matches(&matcher, "API", true));
1865 assert!(matcher_matches(&matcher, "api", true));
1866 }
1867
1868 #[test]
1869 fn malformed_manually_constructed_regex_fails_preparation() {
1870 let mut schema = load_schema("version: 1\nsections: []\n")
1871 .expect("test schema is valid")
1872 .schema;
1873 schema.outline[0].matcher = Matcher::Regex(RegexPattern("(".into()));
1874 let error = PreparedValidator::new(&schema)
1875 .err()
1876 .expect("malformed regex must fail preparation");
1877 assert!(error.message.contains("cannot compile matcher"));
1878 }
1879
1880 #[test]
1881 fn diagnostics_retain_normative_document_and_schema_anchors() {
1882 let loaded =
1883 load_schema("version: 1\ntitle: null\nsections:\n - match: Item\n repeat: 2..2\n")
1884 .expect("test schema is valid");
1885 let document = parse_markdown("## Item\n## Item\n## Item\n", MarkdownOptions::default());
1886 let diagnostics = validate(&loaded.schema, &document).expect("schema prepares");
1887
1888 assert_eq!(diagnostics.len(), 1);
1889 let diagnostic = diagnostics.first().expect("one diagnostic was asserted");
1890 assert_eq!(diagnostic.id, DiagnosticId::TooManySections);
1891 assert_eq!(diagnostic.location.line, 3);
1892 assert_eq!(
1893 diagnostic.target,
1894 DiagnosticTarget::Header(HeaderPath(vec!["Item".into()]))
1895 );
1896 assert_eq!(
1897 diagnostic.schema_node,
1898 Some(SchemaNode::Rule(crate::RulePath {
1899 scope: ScopePath(Vec::new()),
1900 index: RuleIndex(0),
1901 }))
1902 );
1903 }
1904
1905 #[test]
1906 fn header_paths_carry_the_enclosing_h1() {
1907 let loaded = load_schema(
1908 "version: 1\nsections:\n - match: Overview\n repeat: 1..n\n sections:\n - match: Goals\n required: true\n",
1909 )
1910 .expect("test schema is valid");
1911 let document = parse_markdown(
1912 "# Part One\n## Overview\n# Part Two\n## Overview\n",
1913 MarkdownOptions::default(),
1914 );
1915 let targets = validate(&loaded.schema, &document)
1916 .expect("schema prepares")
1917 .into_iter()
1918 .map(|diagnostic| diagnostic.target)
1919 .collect::<Vec<_>>();
1920
1921 assert_eq!(
1927 targets,
1928 [
1929 DiagnosticTarget::Header(HeaderPath(vec!["Part Two".into()])),
1930 DiagnosticTarget::MissingHeader {
1931 parent: HeaderPath(vec!["Part One".into(), "Overview".into()]),
1932 matcher: "Goals".into(),
1933 },
1934 DiagnosticTarget::MissingHeader {
1935 parent: HeaderPath(vec!["Part Two".into(), "Overview".into()]),
1936 matcher: "Goals".into(),
1937 },
1938 ]
1939 );
1940 }
1941
1942 fn surplus_diagnostics(schema: &str, markdown: &str) -> Vec<Diagnostic> {
1943 let loaded = load_schema(schema).expect("test schema is valid");
1944 let document = parse_markdown(markdown, MarkdownOptions::default());
1945 validate(&loaded.schema, &document)
1946 .expect("schema prepares")
1947 .into_iter()
1948 .filter(|diagnostic| diagnostic.id == DiagnosticId::TooManySections)
1949 .collect()
1950 }
1951
1952 fn skipped_diagnostics(schema: &str, markdown: &str) -> Vec<Diagnostic> {
1953 let loaded = load_schema(schema).expect("test schema is valid");
1954 let document = parse_markdown(markdown, MarkdownOptions::default());
1955 validate(&loaded.schema, &document)
1956 .expect("schema prepares")
1957 .into_iter()
1958 .filter(|diagnostic| diagnostic.id == DiagnosticId::SkippedLevel)
1959 .collect()
1960 }
1961
1962 #[test]
1963 fn surplus_h1_headers_are_reported_once_on_the_second_one() {
1964 let schema = "version: 1\nsections:\n - match: Overview\n repeat: 0..n\n";
1965
1966 assert!(surplus_diagnostics(schema, "# One\n## Overview\n## Overview\n").is_empty());
1968 assert!(surplus_diagnostics(schema, "## Overview\n").is_empty());
1971
1972 let two = surplus_diagnostics(schema, "# One\n## Overview\n# Two\n## Overview\n");
1973 assert_eq!(two.len(), 1);
1974 let diagnostic = two.first().expect("one diagnostic was asserted");
1975 assert_eq!(
1977 diagnostic.target,
1978 DiagnosticTarget::Header(HeaderPath(vec!["Two".into()]))
1979 );
1980 assert_eq!(diagnostic.location.line, 3);
1981 assert_eq!(diagnostic.schema_node, Some(SchemaNode::Title));
1984
1985 let three = surplus_diagnostics(schema, "# One\n# Two\n# Three\n## Overview\n");
1987 assert_eq!(three.len(), 1);
1988 assert_eq!(
1989 three[0].target,
1990 DiagnosticTarget::Header(HeaderPath(vec!["Two".into()]))
1991 );
1992 }
1993
1994 #[test]
1995 fn h2_headers_outside_the_documents_h1_skip_against_the_virtual_root() {
1996 let schema = "version: 1\nsections:\n - match: Overview\n repeat: 0..n\n";
1997
1998 let skipped = skipped_diagnostics(schema, "## Overview\n# Part One\n## Overview\n");
2004 assert!(surplus_diagnostics(schema, "## Overview\n# Part One\n## Overview\n").is_empty());
2005 assert_eq!(skipped.len(), 1);
2006 let diagnostic = skipped.first().expect("one diagnostic was asserted");
2007 assert_eq!(
2008 diagnostic.target,
2009 DiagnosticTarget::Header(HeaderPath(vec!["Overview".into()]))
2010 );
2011 assert_eq!(diagnostic.location.line, 1);
2012 assert_eq!(diagnostic.schema_node, None);
2015 assert_eq!(
2016 skipped_diagnostics(
2017 "version: 1\ntitle: Part One\nsections:\n - match: Overview\n repeat: 0..n\n",
2018 "## Overview\n# Part One\n",
2019 )[0]
2020 .schema_node,
2021 None
2022 );
2023
2024 assert!(skipped_diagnostics(schema, "# Part One\n## Overview\n## Overview\n").is_empty());
2027 assert!(skipped_diagnostics(schema, "## Overview\n## Overview\n").is_empty());
2028
2029 let two = skipped_diagnostics(schema, "## A\n## B\n# Part One\n## Overview\n");
2032 assert_eq!(
2033 two.iter()
2034 .map(|diagnostic| diagnostic.target.clone())
2035 .collect::<Vec<_>>(),
2036 [
2037 DiagnosticTarget::Header(HeaderPath(vec!["A".into()])),
2038 DiagnosticTarget::Header(HeaderPath(vec!["B".into()])),
2039 ]
2040 );
2041
2042 assert!(skipped_diagnostics(
2045 schema,
2046 "<!-- outlint-disable skipped-level -->\n## Overview\n# Part One\n",
2047 )
2048 .is_empty());
2049 assert!(skipped_diagnostics(
2050 schema,
2051 "<!-- outlint-disable-file skipped-level -->\n## A\n## B\n# Part One\n",
2052 )
2053 .is_empty());
2054 }
2055
2056 fn ids_and_targets(schema: &str, markdown: &str) -> Vec<(DiagnosticId, DiagnosticTarget)> {
2057 let loaded = load_schema(schema).expect("test schema is valid");
2058 let document = parse_markdown(markdown, MarkdownOptions::default());
2059 validate(&loaded.schema, &document)
2060 .expect("schema prepares")
2061 .into_iter()
2062 .map(|diagnostic| (diagnostic.id, diagnostic.target))
2063 .collect()
2064 }
2065
2066 #[test]
2067 fn an_unadmitted_top_level_header_takes_part_in_no_rule_matching_or_counting() {
2068 assert_eq!(
2072 ids_and_targets(
2073 "version: 1\nsections:\n - match: Detached\n required: true\n",
2074 "## Detached\n# Title\n## Attached\n",
2075 ),
2076 [
2077 (
2078 DiagnosticId::SkippedLevel,
2079 DiagnosticTarget::Header(HeaderPath(vec!["Detached".into()])),
2080 ),
2081 (
2082 DiagnosticId::MissingSection,
2083 DiagnosticTarget::MissingHeader {
2084 parent: HeaderPath::default(),
2085 matcher: "Detached".into(),
2086 },
2087 ),
2088 ]
2089 );
2090
2091 assert_eq!(
2094 ids_and_targets(
2095 "version: 1\nsections:\n - match: Overview\n repeat: 0..1\n",
2096 "## Overview\n# Part One\n## Overview\n",
2097 ),
2098 [(
2099 DiagnosticId::SkippedLevel,
2100 DiagnosticTarget::Header(HeaderPath(vec!["Overview".into()])),
2101 )]
2102 );
2103 }
2104
2105 #[test]
2106 fn an_unadmitted_subtree_is_reported_once_at_its_root() {
2107 assert_eq!(
2112 ids_and_targets(
2113 "version: 1\nsections:\n - match: X\n repeat: 0..n\n strict: true\n sections:\n - match: Deep\n required: true\n",
2114 "## X\n### Surprise\n# Title\n",
2115 ),
2116 [(
2117 DiagnosticId::SkippedLevel,
2118 DiagnosticTarget::Header(HeaderPath(vec!["X".into()])),
2119 )]
2120 );
2121
2122 assert_eq!(
2125 ids_and_targets(
2126 "version: 1\nsections:\n - match: \"*\"\n repeat: 0..n\n",
2127 "## A\n### Under A\n## B\n# Title\n",
2128 ),
2129 [
2130 (
2131 DiagnosticId::SkippedLevel,
2132 DiagnosticTarget::Header(HeaderPath(vec!["A".into()])),
2133 ),
2134 (
2135 DiagnosticId::SkippedLevel,
2136 DiagnosticTarget::Header(HeaderPath(vec!["B".into()])),
2137 ),
2138 ]
2139 );
2140 }
2141
2142 #[test]
2143 fn orphan_headers_skip_against_the_virtual_root() {
2144 let schema = "version: 1\nsections:\n - match: Sec\n repeat: 0..n\n";
2145
2146 assert_eq!(
2149 ids_and_targets(schema, "### Orphan\n# Title\n## Sec\n"),
2150 [(
2151 DiagnosticId::SkippedLevel,
2152 DiagnosticTarget::Header(HeaderPath(vec!["Orphan".into()])),
2153 )]
2154 );
2155
2156 let headless = "version: 1\ntitle: null\nsections:\n - match: Sec\n repeat: 0..n\n";
2159 assert_eq!(
2160 ids_and_targets(headless, "### Orphan\n## Sec\n"),
2161 [(
2162 DiagnosticId::SkippedLevel,
2163 DiagnosticTarget::Header(HeaderPath(vec!["Orphan".into()])),
2164 )]
2165 );
2166
2167 assert_eq!(
2170 ids_and_targets(headless, "### One\n#### Two\n### Three\n"),
2171 [
2172 (
2173 DiagnosticId::SkippedLevel,
2174 DiagnosticTarget::Header(HeaderPath(vec!["One".into()])),
2175 ),
2176 (
2177 DiagnosticId::SkippedLevel,
2178 DiagnosticTarget::Header(HeaderPath(vec!["Three".into()])),
2179 ),
2180 ]
2181 );
2182 }
2183
2184 #[test]
2185 fn level_admission_leaves_unmatched_headers_to_strict_alone() {
2186 let open = "version: 1\nsections:\n - match: Known\n repeat: 0..n\n";
2190 assert_eq!(
2191 ids_and_targets(open, "# Title\n## Known\n## Unmatched\n### Child\n"),
2192 []
2193 );
2194 let open_headless =
2195 "version: 1\ntitle: null\nsections:\n - match: Known\n repeat: 0..n\n";
2196 assert_eq!(
2197 ids_and_targets(open_headless, "## Known\n## Unmatched\n"),
2198 []
2199 );
2200
2201 let closed =
2202 "version: 1\nsections:\n - match: Known\n repeat: 0..n\n strict: true\n";
2203 assert_eq!(
2204 ids_and_targets(closed, "# Title\n## Known\n### Surprise\n"),
2205 [(
2206 DiagnosticId::UnexpectedSection,
2207 DiagnosticTarget::Header(HeaderPath(vec![
2208 "Title".into(),
2209 "Known".into(),
2210 "Surprise".into(),
2211 ])),
2212 )]
2213 );
2214 }
2215
2216 #[test]
2217 fn allow_skipped_levels_admits_top_level_headers_into_the_root_scope() {
2218 let strict_levels = "version: 1\noutline:\n - match: Stray\n required: true\n";
2223 assert_eq!(
2224 ids_and_targets(strict_levels, "## Stray\n"),
2225 [
2226 (
2227 DiagnosticId::SkippedLevel,
2228 DiagnosticTarget::Header(HeaderPath(vec!["Stray".into()])),
2229 ),
2230 (
2231 DiagnosticId::MissingSection,
2232 DiagnosticTarget::MissingHeader {
2233 parent: HeaderPath::default(),
2234 matcher: "Stray".into(),
2235 },
2236 ),
2237 ]
2238 );
2239 let lax_levels = "version: 1\noptions:\n allow_skipped_levels: true\n\
2240 outline:\n - match: Stray\n required: true\n";
2241 assert_eq!(ids_and_targets(lax_levels, "## Stray\n"), []);
2242
2243 let sugar = "version: 1\ntitle: null\nsections:\n - match: Deep\n required: true\n";
2246 assert_eq!(
2247 ids_and_targets(sugar, "### Deep\n"),
2248 [
2249 (
2250 DiagnosticId::SkippedLevel,
2251 DiagnosticTarget::Header(HeaderPath(vec!["Deep".into()])),
2252 ),
2253 (
2254 DiagnosticId::MissingSection,
2255 DiagnosticTarget::MissingHeader {
2256 parent: HeaderPath::default(),
2257 matcher: "Deep".into(),
2258 },
2259 ),
2260 ]
2261 );
2262 let lax_sugar = "version: 1\noptions:\n allow_skipped_levels: true\n\
2263 title: null\nsections:\n - match: Deep\n required: true\n";
2264 assert_eq!(ids_and_targets(lax_sugar, "### Deep\n"), []);
2265 }
2266
2267 #[test]
2268 fn title_null_denies_h1_and_binds_top_level_h2s() {
2269 let schema =
2270 "version: 1\ntitle: null\nsections:\n - match: Overview\n required: true\n";
2271
2272 assert_eq!(ids_and_targets(schema, "## Overview\n"), []);
2275 assert_eq!(
2276 ids_and_targets(schema, "## Wrong\n"),
2277 [(
2278 DiagnosticId::MissingSection,
2279 DiagnosticTarget::MissingHeader {
2280 parent: HeaderPath::default(),
2281 matcher: "Overview".into(),
2282 },
2283 )]
2284 );
2285
2286 let loaded = load_schema(schema).expect("test schema is valid");
2290 let document = parse_markdown(
2291 "## Overview\n# Surprise\n## Hidden\n",
2292 MarkdownOptions::default(),
2293 );
2294 let diagnostics = validate(&loaded.schema, &document).expect("schema prepares");
2295 assert_eq!(diagnostics.len(), 1);
2296 assert_eq!(diagnostics[0].id, DiagnosticId::NotAllowed);
2297 assert_eq!(
2298 diagnostics[0].target,
2299 DiagnosticTarget::Header(HeaderPath(vec!["Surprise".into()]))
2300 );
2301 assert_eq!(diagnostics[0].schema_node, Some(SchemaNode::Title));
2302 assert_eq!(
2303 diagnostics[0].message,
2304 "the schema declares a document with no title"
2305 );
2306 }
2307
2308 #[test]
2309 fn bare_sections_implies_a_required_title() {
2310 let bare = "version: 1\nsections:\n - match: Overview\n required: true\n";
2314 let loaded = load_schema(bare).expect("test schema is valid");
2315 let document = parse_markdown("## Overview\n", MarkdownOptions::default());
2316 let diagnostics = validate(&loaded.schema, &document).expect("schema prepares");
2317 assert_eq!(diagnostics.len(), 1);
2318 let diagnostic = diagnostics.first().expect("one diagnostic was asserted");
2319 assert_eq!(diagnostic.id, DiagnosticId::MissingTitle);
2320 assert_eq!(diagnostic.message, "the document has no required title");
2321 assert_eq!(diagnostic.location, root_location());
2322 assert_eq!(
2323 diagnostic.target,
2324 DiagnosticTarget::MissingHeader {
2325 parent: HeaderPath::default(),
2326 matcher: "*".into(),
2327 }
2328 );
2329 assert_eq!(diagnostic.schema_node, Some(SchemaNode::Title));
2332 let anchor = loaded
2333 .locations
2334 .nodes
2335 .get(&SchemaNode::Title)
2336 .expect("bare sections records a title anchor");
2337 let spelled = &bare[anchor.range.start.0..anchor.range.end.0];
2338 assert_eq!(spelled, "- match: Overview\n required: true\n");
2339
2340 assert_eq!(ids_and_targets(bare, "# Anything\n## Overview\n"), []);
2343 let null = "version: 1\ntitle: null\nsections:\n - match: Overview\n required: true\n";
2344 assert_eq!(ids_and_targets(null, "## Overview\n"), []);
2345
2346 let general = "version: 1\noptions:\n allow_skipped_levels: true\n\
2349 outline:\n - match: Part\n repeat: \"0..n\"\n\
2350 \x20 sections:\n - match: Overview\n required: true\n";
2351 assert_eq!(ids_and_targets(general, ""), []);
2352 }
2353
2354 #[test]
2355 fn a_general_form_h1_that_matches_no_rule_is_an_open_scope_header() {
2356 let schema = "version: 1\noutline:\n - match: \"Guide *\"\n required: true\n";
2360 assert_eq!(
2361 ids_and_targets(schema, "# Handbook\n## Anything\n"),
2362 [(
2363 DiagnosticId::MissingSection,
2364 DiagnosticTarget::MissingHeader {
2365 parent: HeaderPath::default(),
2366 matcher: "Guide *".into(),
2367 },
2368 )]
2369 );
2370 }
2371
2372 #[test]
2373 fn multi_h1_sugar_cardinality_misses_carry_the_owning_h1() {
2374 assert_eq!(
2378 ids_and_targets(
2379 "version: 1\ntitle: \"*\"\nsections:\n - match: Overview\n required: true\n",
2380 "# One\n# Two\n",
2381 ),
2382 [
2383 (
2384 DiagnosticId::TooManySections,
2385 DiagnosticTarget::Header(HeaderPath(vec!["Two".into()])),
2386 ),
2387 (
2388 DiagnosticId::MissingSection,
2389 DiagnosticTarget::MissingHeader {
2390 parent: HeaderPath(vec!["One".into()]),
2391 matcher: "Overview".into(),
2392 },
2393 ),
2394 (
2395 DiagnosticId::MissingSection,
2396 DiagnosticTarget::MissingHeader {
2397 parent: HeaderPath(vec!["Two".into()]),
2398 matcher: "Overview".into(),
2399 },
2400 ),
2401 ]
2402 );
2403
2404 assert_eq!(
2408 ids_and_targets(
2409 "version: 1\ntitle: \"*\"\nsections:\n - match: Overview\n required: true\n",
2410 "# One\n",
2411 ),
2412 [(
2413 DiagnosticId::MissingSection,
2414 DiagnosticTarget::MissingHeader {
2415 parent: HeaderPath::default(),
2416 matcher: "Overview".into(),
2417 },
2418 )]
2419 );
2420 }
2421
2422 #[test]
2423 fn multi_h1_sugar_constraints_target_the_owning_h1() {
2424 let schema = "version: 1\nsections:\n - id: a\n match: A\n required: false\n \
2425 - id: b\n match: B\n required: false\nconstraints:\n - requires: { if: a, then: b }\n";
2426
2427 let single = load_schema(schema).expect("test schema is valid");
2429 let document = parse_markdown("# One\n## A\n", MarkdownOptions::default());
2430 let single_diagnostics = validate(&single.schema, &document).expect("schema prepares");
2431 assert_eq!(single_diagnostics.len(), 1);
2432 assert_eq!(single_diagnostics[0].id, DiagnosticId::Requires);
2433 assert_eq!(single_diagnostics[0].target, DiagnosticTarget::Document);
2434 assert_eq!(single_diagnostics[0].location.line, 1);
2435
2436 let document = parse_markdown("# One\n## A\n# Two\n## A\n", MarkdownOptions::default());
2439 let diagnostics = validate(&single.schema, &document).expect("schema prepares");
2440 let requires = diagnostics
2441 .iter()
2442 .filter(|diagnostic| diagnostic.id == DiagnosticId::Requires)
2443 .map(|diagnostic| (diagnostic.target.clone(), diagnostic.location.line))
2444 .collect::<Vec<_>>();
2445 assert_eq!(
2446 requires,
2447 [
2448 (DiagnosticTarget::Header(HeaderPath(vec!["One".into()])), 1),
2449 (DiagnosticTarget::Header(HeaderPath(vec!["Two".into()])), 3),
2450 ]
2451 );
2452 }
2453
2454 #[test]
2455 fn an_admitted_top_level_h2_never_occupies_the_title_slot() {
2456 let schema = "version: 1\noptions:\n allow_skipped_levels: true\ntitle: \"*\"\n\
2464 sections:\n - match: Overview\n required: true\n";
2465 assert_eq!(
2466 ids_and_targets(schema, "## Intro\n# Doc\n## Overview\n"),
2467 []
2468 );
2469 }
2470
2471 #[test]
2472 fn an_admitted_top_level_h2_binds_the_titled_documents_sections_scope() {
2473 let schema = "version: 1\noptions:\n allow_skipped_levels: true\ntitle: \"*\"\n\
2481 sections:\n - match: Intro\n required: true\n";
2482 assert_eq!(ids_and_targets(schema, "## Intro\n# Doc\n"), []);
2483 }
2484
2485 #[test]
2486 fn surplus_titles_blame_the_spelled_or_implied_title() {
2487 let titled = surplus_diagnostics(
2488 "version: 1\ntitle: Project\nsections:\n - match: Item\n repeat: 0..n\n",
2489 "# Project\n# Project\n## Item\n",
2490 );
2491 assert_eq!(titled.len(), 1);
2492 let diagnostic = titled.first().expect("one diagnostic was asserted");
2493 assert_eq!(diagnostic.schema_node, Some(SchemaNode::Title));
2494 assert_eq!(diagnostic.message, "the document has more than one title");
2495
2496 let untitled = surplus_diagnostics(
2500 "version: 1\nsections:\n - match: Item\n repeat: 0..n\n",
2501 "# Project\n# Project\n## Item\n",
2502 );
2503 assert_eq!(untitled.len(), 1);
2504 assert_eq!(untitled[0].schema_node, Some(SchemaNode::Title));
2505 assert_eq!(untitled[0].message, "the document has more than one title");
2506 }
2507
2508 #[test]
2509 fn a_surplus_header_carries_its_own_inline_suppression() {
2510 assert!(surplus_diagnostics(
2511 "version: 1\nsections:\n - match: Overview\n repeat: 0..n\n",
2512 "# One\n## Overview\n<!-- outlint-disable too-many-sections -->\n# Two\n",
2513 )
2514 .is_empty());
2515 }
2516
2517 #[test]
2518 fn root_scope_violations_name_the_document_rather_than_a_header() {
2519 let loaded = load_schema(
2520 "version: 1\nsections:\n - id: a\n match: A\n required: true\n - id: b\n match: B\n required: true\nconstraints:\n - all_or_none: [a, b]\n",
2521 )
2522 .expect("test schema is valid");
2523 let document = parse_markdown("# Part One\n## B\n", MarkdownOptions::default());
2524 let targets = validate(&loaded.schema, &document)
2525 .expect("schema prepares")
2526 .into_iter()
2527 .map(|diagnostic| diagnostic.target)
2528 .collect::<Vec<_>>();
2529
2530 assert_eq!(
2534 targets,
2535 [
2536 DiagnosticTarget::MissingHeader {
2537 parent: HeaderPath::default(),
2538 matcher: "A".into(),
2539 },
2540 DiagnosticTarget::Document,
2541 ]
2542 );
2543 }
2544
2545 #[test]
2546 fn unexpected_section_points_to_the_rule_that_closed_its_scope() {
2547 let loaded = load_schema("version: 1\nsections:\n - match: Parent\n strict: true\n")
2548 .expect("test schema is valid");
2549 let document = parse_markdown("## Parent\n### Surprise\n", MarkdownOptions::default());
2550 let diagnostics = validate(&loaded.schema, &document).expect("schema prepares");
2551
2552 let diagnostic = diagnostics
2553 .iter()
2554 .find(|diagnostic| diagnostic.id == DiagnosticId::UnexpectedSection)
2555 .expect("the strict child scope rejects Surprise");
2556 assert_eq!(
2557 diagnostic.schema_node,
2558 Some(SchemaNode::Rule(crate::RulePath {
2559 scope: ScopePath(Vec::new()),
2560 index: RuleIndex(0),
2561 }))
2562 );
2563 }
2564
2565 #[test]
2566 fn validates_required_frontmatter_against_json_schema() {
2567 let mut schema = load_schema("version: 1\nfrontmatter: { required: true }\nsections: []\n")
2568 .expect("test schema is valid")
2569 .schema;
2570 let object = serde_json::json!({
2571 "$schema": "https://json-schema.org/draft/2020-12/schema",
2572 "type": "object",
2573 "required": ["status"],
2574 "properties": { "status": { "enum": ["draft", "final"] } }
2575 });
2576 schema.frontmatter = FrontmatterPolicy::Required {
2577 schema: Some(FrontmatterSchema {
2578 root_uri: "https://outlint.invalid/root.json".into(),
2579 root: object,
2580 resources: std::collections::BTreeMap::new(),
2581 }),
2582 };
2583
2584 let absent = parse_markdown("# Title\n", MarkdownOptions::default());
2585 assert_eq!(
2586 validate(&schema, &absent).expect("schema prepares")[0].id,
2587 DiagnosticId::MissingFrontmatter
2588 );
2589
2590 let invalid = parse_markdown(
2591 "---\nstatus: proposed\n---\n# Title\n",
2592 MarkdownOptions::default(),
2593 );
2594 let diagnostics = validate(&schema, &invalid).expect("schema prepares");
2595 assert_eq!(diagnostics.len(), 1);
2596 assert_eq!(diagnostics[0].id, DiagnosticId::FrontmatterSchema);
2597 let DiagnosticTarget::Frontmatter { block: Some(block) } = &diagnostics[0].target else {
2598 panic!("a frontmatter schema diagnostic targets a present block");
2599 };
2600 assert_eq!(block.json_pointer.as_deref(), Some("/status"));
2601 assert_eq!(
2602 (block.line_range.start_line, block.line_range.end_line),
2603 (1, 3)
2604 );
2605 assert_eq!(
2606 diagnostics[0].schema_node,
2607 Some(SchemaNode::FrontmatterSchemaDocument)
2608 );
2609
2610 let valid = parse_markdown(
2611 "---\nstatus: final\n---\n# Title\n",
2612 MarkdownOptions::default(),
2613 );
2614 assert!(validate(&schema, &valid)
2615 .expect("schema prepares")
2616 .is_empty());
2617 }
2618
2619 #[test]
2620 fn frontmatter_schema_messages_quote_document_number_spellings() {
2621 let mut schema = load_schema("version: 1\ntitle: null\nsections: []\n")
2622 .expect("test schema is valid")
2623 .schema;
2624 schema.frontmatter = FrontmatterPolicy::Optional {
2625 schema: Some(FrontmatterSchema {
2626 root_uri: "https://outlint.invalid/root.json".into(),
2627 root: serde_json::json!({
2628 "type": "object",
2629 "properties": {
2630 "whole": { "maximum": 1 },
2631 "fraction": { "maximum": 1 },
2632 "lower_exponent": { "maximum": 1 },
2633 "upper_exponent": { "maximum": 1 }
2634 }
2635 }),
2636 resources: std::collections::BTreeMap::new(),
2637 }),
2638 };
2639 let document = parse_markdown(
2640 "---\nwhole: 100.0\nfraction: 1.5\nlower_exponent: 1e2\nupper_exponent: 1E2\n---\n",
2641 MarkdownOptions::default(),
2642 );
2643 let messages = validate(&schema, &document)
2644 .expect("schema prepares")
2645 .into_iter()
2646 .map(|diagnostic| diagnostic.message)
2647 .collect::<Vec<_>>();
2648
2649 assert_eq!(
2650 messages,
2651 [
2652 "1.5 is greater than the maximum of 1",
2653 "1e2 is greater than the maximum of 1",
2654 "1E2 is greater than the maximum of 1",
2655 "100.0 is greater than the maximum of 1",
2656 ]
2657 );
2658 }
2659
2660 #[test]
2661 fn manually_constructed_frontmatter_schema_denies_remote_retrieval() {
2662 let remote_uri = "https://example.invalid/frontmatter.schema.json";
2663 let mut schema = load_schema("version: 1\nsections: []\n")
2664 .expect("test schema is valid")
2665 .schema;
2666 schema.frontmatter = FrontmatterPolicy::Optional {
2667 schema: Some(FrontmatterSchema {
2668 root_uri: "https://outlint.invalid/root.json".into(),
2669 root: serde_json::json!({"$ref": remote_uri}),
2670 resources: std::collections::BTreeMap::new(),
2671 }),
2672 };
2673
2674 let error = match PreparedValidator::new(&schema) {
2675 Err(error) => error,
2676 Ok(_) => panic!("remote refs cannot be retrieved during preparation"),
2677 };
2678 assert!(
2679 error.message.contains(&format!(
2680 "JSON Schema resource `{remote_uri}` was not preloaded"
2681 )),
2682 "unexpected retrieval diagnostic: {}",
2683 error.message
2684 );
2685 assert!(
2686 !error.message.contains("Default retriever"),
2687 "unexpected retrieval diagnostic: {}",
2688 error.message
2689 );
2690 }
2691
2692 #[test]
2693 fn reports_invalid_and_forbidden_frontmatter_without_schema_execution() {
2694 let schema =
2695 load_schema("version: 1\nfrontmatter: { allow: false }\ntitle: null\nsections: []\n")
2696 .expect("test schema is valid")
2697 .schema;
2698 let document = parse_markdown("---\n- item\n---\n", MarkdownOptions::default());
2699 let ids = validate(&schema, &document)
2700 .expect("schema prepares")
2701 .into_iter()
2702 .map(|diagnostic| diagnostic.id)
2703 .collect::<Vec<_>>();
2704 assert_eq!(
2705 ids,
2706 [
2707 DiagnosticId::ForbiddenFrontmatter,
2708 DiagnosticId::InvalidFrontmatter
2709 ]
2710 );
2711 }
2712
2713 #[test]
2714 fn optional_forbidden_and_file_suppression_apply_to_json_schema() {
2715 let json_schema = FrontmatterSchema {
2716 root_uri: "https://outlint.invalid/root.json".into(),
2717 root: serde_json::Value::Bool(false),
2718 resources: std::collections::BTreeMap::new(),
2719 };
2720 let mut schema = load_schema("version: 1\ntitle: null\nsections: []\n")
2721 .expect("test schema is valid")
2722 .schema;
2723 schema.frontmatter = FrontmatterPolicy::Optional {
2724 schema: Some(json_schema.clone()),
2725 };
2726 let absent = parse_markdown("## Title\n", MarkdownOptions::default());
2727 assert!(validate(&schema, &absent)
2728 .expect("schema prepares")
2729 .is_empty());
2730
2731 let suppressed = parse_markdown(
2732 "---\nstatus: draft\n---\n<!-- outlint-disable-file frontmatter-schema -->\n",
2733 MarkdownOptions::default(),
2734 );
2735 assert!(validate(&schema, &suppressed)
2736 .expect("schema prepares")
2737 .is_empty());
2738
2739 schema.frontmatter = FrontmatterPolicy::Forbidden {
2740 schema: Some(json_schema),
2741 };
2742 let present = parse_markdown("---\nstatus: draft\n---\n", MarkdownOptions::default());
2743 let ids = validate(&schema, &present)
2744 .expect("schema prepares")
2745 .into_iter()
2746 .map(|diagnostic| diagnostic.id)
2747 .collect::<Vec<_>>();
2748 assert_eq!(
2749 ids,
2750 [
2751 DiagnosticId::ForbiddenFrontmatter,
2752 DiagnosticId::FrontmatterSchema
2753 ]
2754 );
2755 }
2756
2757 #[test]
2758 fn preparing_refuses_a_reference_chain_longer_than_the_compiler_can_recurse_over() {
2759 let document = parse_markdown("---\nstatus: draft\n---\n", MarkdownOptions::default());
2769
2770 let mut schema = load_schema("version: 1\ntitle: null\nsections: []\n")
2771 .expect("test schema is valid")
2772 .schema;
2773 schema.frontmatter = FrontmatterPolicy::Optional {
2774 schema: Some(reference_chain_schema(MAX_JSON_SCHEMA_REFERENCES - 1)),
2775 };
2776 assert!(validate(&schema, &document)
2777 .expect("a graph spending the whole budget still prepares")
2778 .is_empty());
2779
2780 schema.frontmatter = FrontmatterPolicy::Optional {
2781 schema: Some(reference_chain_schema(MAX_JSON_SCHEMA_REFERENCES)),
2782 };
2783 let error = validate(&schema, &document).expect_err("one reference more is refused");
2784 assert_eq!(error.message, json_schema_reference_budget_message());
2785 }
2786
2787 fn reference_chain_schema(links: usize) -> FrontmatterSchema {
2790 let mut definitions = serde_json::Map::new();
2791 definitions.insert("end".into(), serde_json::Value::Bool(true));
2792 for index in 0..links {
2793 let target = if index + 1 == links {
2794 "#/$defs/end".to_owned()
2795 } else {
2796 format!("#/$defs/{}", index + 1)
2797 };
2798 definitions.insert(index.to_string(), serde_json::json!({ "$ref": target }));
2799 }
2800 FrontmatterSchema {
2801 root_uri: "https://outlint.invalid/root.json".into(),
2802 root: serde_json::json!({ "$ref": "#/$defs/0", "$defs": definitions }),
2803 resources: std::collections::BTreeMap::new(),
2804 }
2805 }
2806
2807 fn fm_reference(path: &[&str], equals: Option<&str>) -> crate::FrontmatterRef {
2810 let mut keys = path.iter();
2811 crate::FrontmatterRef {
2812 path: crate::NonEmpty {
2813 first: crate::FrontmatterKey(
2814 (*keys.next().expect("test paths are non-empty")).to_owned(),
2815 ),
2816 rest: keys
2817 .map(|key| crate::FrontmatterKey((*key).to_owned()))
2818 .collect(),
2819 },
2820 equals: equals.map(parse_frontmatter_scalar),
2821 }
2822 }
2823
2824 fn fm_satisfied(markdown: &str, path: &[&str], equals: Option<&str>, match_case: bool) -> bool {
2827 let document = parse_markdown(markdown, MarkdownOptions::default());
2828 let frontmatter = match &document.frontmatter {
2829 DocumentFrontmatter::Mapping { value, .. } => Some(value),
2830 DocumentFrontmatter::Absent | DocumentFrontmatter::Invalid { .. } => None,
2831 };
2832 frontmatter_satisfied(frontmatter, &fm_reference(path, equals), match_case)
2833 }
2834
2835 #[test]
2836 fn bare_frontmatter_refs_are_presence_of_a_non_null_value() {
2837 let document = "---\npresent: 1\nempty: null\nnested:\n inner: yes\n---\n";
2838 assert!(fm_satisfied(document, &["present"], None, false));
2839 assert!(!fm_satisfied(document, &["empty"], None, false));
2842 assert!(!fm_satisfied(document, &["absent"], None, false));
2843 assert!(fm_satisfied(document, &["nested", "inner"], None, false));
2845 assert!(!fm_satisfied(document, &["nested", "missing"], None, false));
2846 assert!(!fm_satisfied(document, &["present", "deeper"], None, false));
2848 assert!(!fm_satisfied("# Title\n", &["present"], None, false));
2850 }
2851
2852 #[test]
2853 fn bare_refs_accept_collections_but_equality_refuses_them() {
2854 let document = "---\nitems:\n - one\ntable:\n key: value\n---\n";
2855 assert!(fm_satisfied(document, &["items"], None, false));
2858 assert!(fm_satisfied(document, &["table"], None, false));
2859 assert!(!fm_satisfied(document, &["items"], Some("one"), false));
2860 assert!(!fm_satisfied(document, &["table"], Some("value"), false));
2861 assert!(!fm_satisfied(document, &["items", "one"], None, false));
2863 }
2864
2865 #[test]
2866 fn equality_is_typed_by_the_core_schema_resolver() {
2867 let document = "---\ncount: 1\nspelled: \"1\"\ndraft: true\nquoted: \"true\"\n---\n";
2868 assert!(fm_satisfied(document, &["count"], Some("1"), false));
2869 assert!(fm_satisfied(document, &["draft"], Some("true"), false));
2870 assert!(!fm_satisfied(document, &["spelled"], Some("\"1\""), false));
2873 assert!(!fm_satisfied(document, &["spelled"], Some("1"), false));
2875 assert!(!fm_satisfied(document, &["quoted"], Some("true"), false));
2876 assert!(!fm_satisfied(document, &["count"], Some("1.0"), false));
2877 let spellings = "---\nhex: 0x10\nfloat: 12.5\n---\n";
2880 assert!(fm_satisfied(spellings, &["hex"], Some("16"), false));
2881 assert!(fm_satisfied(spellings, &["float"], Some("1.25e1"), false));
2882 assert!(!fm_satisfied(spellings, &["hex"], Some("16.0"), false));
2883 assert!(!fm_satisfied(
2885 "---\nempty: null\n---\n",
2886 &["empty"],
2887 Some("null"),
2888 false
2889 ));
2890 }
2891
2892 #[test]
2893 fn string_equality_follows_match_case_with_simple_folding() {
2894 let document = "---\nstatus: Deprecated\nfold: \u{17f}\n---\n";
2895 assert!(fm_satisfied(
2896 document,
2897 &["status"],
2898 Some("deprecated"),
2899 false
2900 ));
2901 assert!(!fm_satisfied(
2902 document,
2903 &["status"],
2904 Some("deprecated"),
2905 true
2906 ));
2907 assert!(fm_satisfied(
2908 document,
2909 &["status"],
2910 Some("Deprecated"),
2911 true
2912 ));
2913 assert!(fm_satisfied(document, &["fold"], Some("S"), false));
2915 assert!(!fm_satisfied(document, &["fold"], Some("S"), true));
2916 }
2917
2918 #[test]
2919 fn deep_nesting_resolves_one_mapping_per_step() {
2920 let document = "---\na:\n b:\n c:\n d: leaf\n---\n";
2921 assert!(fm_satisfied(document, &["a", "b", "c", "d"], None, false));
2922 assert!(fm_satisfied(
2923 document,
2924 &["a", "b", "c", "d"],
2925 Some("leaf"),
2926 false
2927 ));
2928 assert!(!fm_satisfied(
2929 document,
2930 &["a", "b", "c", "d", "e"],
2931 None,
2932 false
2933 ));
2934 assert!(!fm_satisfied(
2935 document,
2936 &["a", "b", "c"],
2937 Some("leaf"),
2938 false
2939 ));
2940 }
2941
2942 #[test]
2943 fn frontmatter_constraints_fire_and_release_through_validation() {
2944 let loaded = load_schema(
2945 "version: 1\nsections:\n - id: migration\n match: Migration\n \
2946 required: false\nconstraints:\n - requires: { if: fm.status=deprecated, \
2947 then: migration }\n",
2948 )
2949 .expect("test schema is valid");
2950
2951 let firing = parse_markdown(
2952 "---\nstatus: deprecated\n---\n# Doc\n",
2953 MarkdownOptions::default(),
2954 );
2955 let diagnostics = validate(&loaded.schema, &firing).expect("schema prepares");
2956 assert_eq!(diagnostics.len(), 1);
2957 let diagnostic = &diagnostics[0];
2958 assert_eq!(diagnostic.id, DiagnosticId::Requires);
2959 assert_eq!(diagnostic.target, DiagnosticTarget::Document);
2963 assert_eq!(
2964 diagnostic.references[0],
2965 DiagnosticReference::Frontmatter(fm_reference(&["status"], Some("deprecated"))),
2966 );
2967
2968 let inert = parse_markdown(
2970 "---\nstatus: current\n---\n# Doc\n",
2971 MarkdownOptions::default(),
2972 );
2973 assert!(validate(&loaded.schema, &inert)
2974 .expect("schema prepares")
2975 .is_empty());
2976
2977 let satisfied = parse_markdown(
2979 "---\nstatus: deprecated\n---\n# Doc\n## Migration\n",
2980 MarkdownOptions::default(),
2981 );
2982 assert!(validate(&loaded.schema, &satisfied)
2983 .expect("schema prepares")
2984 .is_empty());
2985 }
2986
2987 #[test]
2988 fn fm_refs_read_frontmatter_even_when_a_nested_rule_is_addressable_as_fm_x() {
2989 let loaded = load_schema(
2994 "version: 1\nsections:\n - id: outer\n match: Outer\n required: false\n \
2995 sections:\n - id: fm\n match: FM\n required: false\n \
2996 sections:\n - id: x\n match: X\n required: false\n \
2997 constraints:\n - requires: { if: fm.x, then: fm.present }\n",
2998 )
2999 .expect("only a top-level `fm` rule id is reserved");
3000
3001 let headers_only = parse_markdown(
3005 "# Doc\n## Outer\n### FM\n#### X\n",
3006 MarkdownOptions::default(),
3007 );
3008 assert!(validate(&loaded.schema, &headers_only)
3009 .expect("schema prepares")
3010 .is_empty());
3011
3012 let frontmatter_only = parse_markdown(
3014 "---\nx: 1\n---\n# Doc\n## Outer\n",
3015 MarkdownOptions::default(),
3016 );
3017 let diagnostics = validate(&loaded.schema, &frontmatter_only).expect("schema prepares");
3018 assert_eq!(diagnostics.len(), 1);
3019 assert_eq!(diagnostics[0].id, DiagnosticId::Requires);
3020 }
3021
3022 fn ordered_diagnostics(schema: &str, markdown: &str) -> Vec<Diagnostic> {
3023 let loaded = load_schema(schema).expect("test schema is valid");
3024 let document = parse_markdown(markdown, MarkdownOptions::default());
3025 validate(&loaded.schema, &document)
3026 .expect("schema prepares")
3027 .into_iter()
3028 .filter(|diagnostic| diagnostic.id == DiagnosticId::Ordered)
3029 .collect()
3030 }
3031
3032 #[test]
3033 fn a_scope_orders_its_rules_by_default() {
3034 let schema =
3039 "version: 1\nsections:\n - match: Overview\n - match: Usage\n - match: Notes\n";
3040 assert_eq!(
3041 ids_and_targets(schema, "# T\n## Overview\n## Usage\n## Notes\n"),
3042 []
3043 );
3044 let diagnostics = ordered_diagnostics(schema, "# T\n## Usage\n## Overview\n## Notes\n");
3045 assert_eq!(diagnostics.len(), 1);
3046 let diagnostic = &diagnostics[0];
3047 assert_eq!(diagnostic.target, DiagnosticTarget::Document);
3048 assert_eq!(diagnostic.schema_node, Some(SchemaNode::Title));
3049 assert_eq!(diagnostic.location.line, 1);
3050 assert!(diagnostic.references.is_empty());
3051 assert_eq!(
3052 diagnostic.message,
3053 "sections are out of the declared order: `Overview` must precede `Usage`"
3054 );
3055 assert_eq!(
3057 diagnostic
3058 .involved_headers
3059 .iter()
3060 .map(|header| header.path.clone())
3061 .collect::<Vec<_>>(),
3062 [
3063 HeaderPath(vec!["T".into(), "Usage".into()]),
3064 HeaderPath(vec!["T".into(), "Overview".into()]),
3065 ]
3066 );
3067 }
3068
3069 #[test]
3070 fn implicit_order_reports_each_broken_adjacent_pair() {
3071 let schema = "version: 1\nsections:\n - match: A\n - match: B\n - match: C\n";
3075 let reversed = ordered_diagnostics(schema, "# T\n## C\n## B\n## A\n");
3076 assert_eq!(
3077 reversed
3078 .iter()
3079 .map(|diagnostic| diagnostic.message.as_str())
3080 .collect::<Vec<_>>(),
3081 [
3082 "sections are out of the declared order: `A` must precede `B`",
3083 "sections are out of the declared order: `B` must precede `C`",
3084 ]
3085 );
3086 let displaced = ordered_diagnostics(schema, "# T\n## A\n## C\n## B\n");
3087 assert_eq!(displaced.len(), 1);
3088 assert_eq!(
3089 displaced[0].message,
3090 "sections are out of the declared order: `B` must precede `C`"
3091 );
3092 }
3093
3094 #[test]
3095 fn implicit_order_ignores_unmatched_and_denied_headers_and_absent_rules() {
3096 let schema = "version: 1\nsections:\n - match: A\n - match: B\n required: false\n - match: C\n - match: X\n allow: false\n";
3100 assert_eq!(
3101 ids_and_targets(schema, "# T\n## Free\n## A\n## Free\n## C\n## Free\n"),
3102 []
3103 );
3104 assert_eq!(
3105 ids_and_targets(schema, "# T\n## X\n## A\n## C\n"),
3106 [(
3107 DiagnosticId::NotAllowed,
3108 DiagnosticTarget::Header(HeaderPath(vec!["T".into(), "X".into()])),
3109 )]
3110 );
3111 }
3112
3113 #[test]
3114 fn implicit_order_compares_all_occurrences_of_repeated_rules() {
3115 let schema = "version: 1\nsections:\n - match: \"A *\"\n - match: \"B *\"\n";
3118 assert_eq!(
3119 ids_and_targets(schema, "# T\n## A 1\n## A 2\n## B 1\n## B 2\n"),
3120 []
3121 );
3122 assert_eq!(
3123 ids_and_targets(schema, "# T\n## A 1\n## B 1\n## A 2\n"),
3124 [(DiagnosticId::Ordered, DiagnosticTarget::Document)]
3125 );
3126 }
3127
3128 #[test]
3129 fn nested_and_outline_scopes_order_themselves_with_their_own_owners() {
3130 let nested = "version: 1\nsections:\n - match: Steps\n sections:\n - match: One\n - match: Two\n";
3135 let diagnostics = ordered_diagnostics(nested, "# T\n## Steps\n### Two\n### One\n");
3136 assert_eq!(diagnostics.len(), 1);
3137 assert_eq!(
3138 diagnostics[0].target,
3139 DiagnosticTarget::Header(HeaderPath(vec!["T".into(), "Steps".into()]))
3140 );
3141 assert_eq!(
3142 diagnostics[0].schema_node,
3143 Some(SchemaNode::Rule(crate::RulePath {
3144 scope: ScopePath(Vec::new()),
3145 index: RuleIndex(0),
3146 }))
3147 );
3148 assert_eq!(diagnostics[0].location.line, 2);
3149
3150 let outline = "version: 1\noutline:\n - match: Intro\n - match: Part\n";
3151 let diagnostics = ordered_diagnostics(outline, "# Part\n# Intro\n");
3152 assert_eq!(diagnostics.len(), 1);
3153 assert_eq!(diagnostics[0].target, DiagnosticTarget::Document);
3154 assert_eq!(diagnostics[0].schema_node, None);
3155 }
3156
3157 #[test]
3158 fn the_option_sets_the_default_and_a_rule_overrides_it_for_its_scope() {
3159 let unordered = "version: 1\noptions:\n ordered_sections: false\nsections:\n - match: A\n - match: B\n";
3160 assert_eq!(ids_and_targets(unordered, "# T\n## B\n## A\n"), []);
3161
3162 let opted_in = "version: 1\noptions:\n ordered_sections: false\nsections:\n - match: S\n ordered: true\n sections:\n - match: A\n - match: B\n";
3164 assert_eq!(
3165 ids_and_targets(opted_in, "# T\n## S\n### B\n### A\n"),
3166 [(
3167 DiagnosticId::Ordered,
3168 DiagnosticTarget::Header(HeaderPath(vec!["T".into(), "S".into()])),
3169 )]
3170 );
3171 let opted_out = "version: 1\nsections:\n - match: S\n ordered: false\n sections:\n - match: A\n - match: B\n";
3172 assert_eq!(ids_and_targets(opted_out, "# T\n## S\n### B\n### A\n"), []);
3173 }
3174
3175 #[test]
3176 fn implicit_order_binds_per_instance_and_speaks_for_each_owner() {
3177 let schema = "version: 1\nsections:\n - match: A\n - match: B\n";
3181 let diagnostics = ordered_diagnostics(schema, "# One\n## A\n## B\n# Two\n## B\n## A\n");
3182 assert_eq!(diagnostics.len(), 1);
3183 assert_eq!(
3184 diagnostics[0].target,
3185 DiagnosticTarget::Header(HeaderPath(vec!["Two".into()]))
3186 );
3187 assert_eq!(diagnostics[0].location.line, 4);
3188 }
3189
3190 #[test]
3191 fn implicit_order_is_suppressible_at_the_owning_header() {
3192 let schema = "version: 1\nsections:\n - match: S\n sections:\n - match: A\n - match: B\n";
3193 assert_eq!(
3194 ids_and_targets(
3195 schema,
3196 "# T\n<!-- outlint-disable ordered -->\n## S\n### B\n### A\n"
3197 ),
3198 []
3199 );
3200 }
3201
3202 #[test]
3203 fn explicit_ordered_compares_all_occurrences_of_repeated_refs() {
3204 let schema = "version: 1\noptions:\n ordered_sections: false\nsections:\n - id: a\n match: \"A *\"\n - id: b\n match: \"B *\"\nconstraints:\n - ordered: [a, b]\n";
3209 assert_eq!(
3210 ids_and_targets(schema, "# T\n## A 1\n## A 2\n## B 1\n## B 2\n"),
3211 []
3212 );
3213 let diagnostics = ordered_diagnostics(schema, "# T\n## A 1\n## B 1\n## A 2\n");
3214 assert_eq!(diagnostics.len(), 1);
3215 assert_eq!(diagnostics[0].target, DiagnosticTarget::Document);
3216 assert_eq!(
3217 diagnostics[0].schema_node,
3218 Some(SchemaNode::Constraint(ConstraintPath {
3219 scope: ScopePath(Vec::new()),
3220 index: ConstraintIndex(0),
3221 }))
3222 );
3223 assert_eq!(diagnostics[0].references.len(), 2);
3225 let reversed = "version: 1\noptions:\n ordered_sections: false\nsections:\n - id: a\n match: A\n - id: b\n match: B\nconstraints:\n - ordered: [b, a]\n";
3228 assert_eq!(ids_and_targets(reversed, "# T\n## B\n## A\n"), []);
3229 assert_eq!(
3230 ids_and_targets(reversed, "# T\n## A\n## B\n"),
3231 [(DiagnosticId::Ordered, DiagnosticTarget::Document)]
3232 );
3233 }
3234
3235 #[test]
3236 fn explicit_ordered_binds_per_instance_and_never_reaches_across_ancestors() {
3237 let schema = "version: 1\noptions:\n ordered_sections: false\nsections:\n - id: intro\n match: Intro\n - id: body\n match: Body\nconstraints:\n - ordered: [intro, body]\n";
3242 let diagnostics = ordered_diagnostics(
3243 schema,
3244 "# One\n## Intro\n## Body\n# Two\n## Body\n## Intro\n",
3245 );
3246 assert_eq!(diagnostics.len(), 1);
3247 assert_eq!(
3248 diagnostics[0].target,
3249 DiagnosticTarget::Header(HeaderPath(vec!["Two".into()]))
3250 );
3251 assert!(ordered_diagnostics(schema, "# Alpha\n## Body\n# Beta\n## Intro\n").is_empty());
3252 }
3253
3254 #[test]
3255 fn explicit_ordered_on_the_outline_root_targets_the_document() {
3256 let schema = "version: 1\noptions:\n ordered_sections: false\noutline:\n - id: guide\n match: Guide\n required: true\n - id: appendix\n match: Appendix\n repeat: \"0..1\"\nconstraints:\n - ordered: [guide, appendix]\n";
3257 assert_eq!(ids_and_targets(schema, "# Guide\n# Appendix\n"), []);
3258 let diagnostics = ordered_diagnostics(schema, "# Appendix\n# Guide\n");
3259 assert_eq!(diagnostics.len(), 1);
3260 assert_eq!(diagnostics[0].target, DiagnosticTarget::Document);
3261 assert_eq!(diagnostics[0].location.line, 1);
3262 assert_eq!(
3263 diagnostics[0].schema_node,
3264 Some(SchemaNode::Constraint(ConstraintPath {
3265 scope: ScopePath(Vec::new()),
3266 index: ConstraintIndex(0),
3267 }))
3268 );
3269 }
3270}