Skip to main content

outlint_core/
validator.rs

1//! Pure validation of a parsed Markdown outline against a normalized schema.
2//!
3//! Validation is deliberately separate from parsing and IO: callers can load
4//! and parse fixture text once, then pass only values to [`validate`].
5
6use 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/// A stable identifier from the diagnostic vocabulary in specification §6.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22#[non_exhaustive]
23pub enum DiagnosticId {
24    /// A heading is more than one level below its nearest parent.
25    SkippedLevel,
26    /// A present heading is denied by its first matching rule or title matcher.
27    NotAllowed,
28    /// A heading has no matching rule in a strict scope.
29    UnexpectedSection,
30    /// No heading matched a rule whose minimum is nonzero.
31    MissingSection,
32    /// Some headings matched a rule, but fewer than its minimum.
33    TooFewSections,
34    /// More headings matched a rule than its finite maximum, or the document
35    /// holds more than one `h1` under a sugar schema.
36    TooManySections,
37    /// The schema declares a title but the document has none.
38    MissingTitle,
39    /// A required frontmatter block is absent.
40    MissingFrontmatter,
41    /// A present frontmatter block is forbidden by the schema.
42    ForbiddenFrontmatter,
43    /// A frontmatter block is not a valid YAML mapping.
44    InvalidFrontmatter,
45    /// A frontmatter value fails its JSON Schema.
46    FrontmatterSchema,
47    /// An `one_of` constraint does not have exactly one satisfied ref.
48    OneOf,
49    /// An `any_of` constraint has no satisfied ref.
50    AnyOf,
51    /// An `at_most_one` constraint has more than one satisfied ref.
52    AtMostOne,
53    /// An `all_or_none` constraint has some but not all refs satisfied.
54    AllOrNone,
55    /// A `requires` condition is satisfied without every consequence.
56    Requires,
57    /// A `conflicts` condition and at least one exclusion are both satisfied.
58    Conflicts,
59    /// Concrete occurrences violate an explicit constraint or a scope's rule order.
60    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    /// Returns the public, suppression-compatible spelling of this id.
71    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/// A path of case-preserving visible heading texts.
96///
97/// A header path is always the complete document-tree ancestor chain, from the
98/// document's topmost enclosing heading down to the header itself. It does not
99/// begin at the root scope: an enclosing `h1`, which is the title when the
100/// document has one, is part of the path. Two same-named sections under
101/// different ancestors therefore have different paths.
102#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
103#[repr(transparent)]
104pub struct HeaderPath(pub Vec<String>);
105
106impl HeaderPath {
107    /// Returns the heading texts in ancestor-to-descendant order.
108    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/// A source anchor in the Markdown document.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
127pub struct DiagnosticLocation {
128    /// The source line to highlight.
129    pub range: TextRange,
130    /// One-based line number.
131    pub line: u64,
132    /// One-based byte column.
133    pub column: u64,
134}
135
136/// A concrete header relevant to a constraint violation.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct InvolvedHeader {
139    /// The concrete header's document path.
140    pub path: HeaderPath,
141    /// The concrete header's source anchor.
142    pub location: DiagnosticLocation,
143}
144
145/// A normalized constraint reference retained for diagnostic presentation.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum DiagnosticReference {
148    /// A rule reference paired with its resolved target matcher.
149    Rule {
150        /// The normalized relative or schema-root-anchored reference.
151        reference: RuleRef,
152        /// Matcher of the rule targeted by `reference`.
153        matcher: Matcher,
154    },
155    /// A document-level frontmatter proposition.
156    Frontmatter(FrontmatterRef),
157}
158
159/// What a diagnostic is about.
160///
161/// The four cases carry text of different provenance, and conflating them in
162/// one [`HeaderPath`] silently mixes document text with schema text. Only
163/// [`Self::Header`] names text that occurs in the document; the matcher label
164/// in [`Self::MissingHeader`] comes from the schema and may occur nowhere in
165/// the document; [`Self::Document`] and [`Self::Frontmatter`] name no header
166/// at all.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum DiagnosticTarget {
169    /// A header that exists in the document, named by its document path.
170    Header(HeaderPath),
171    /// A section the schema requires but the document does not contain.
172    MissingHeader {
173        /// Document path of the header whose scope should have contained it.
174        ///
175        /// Empty when no header encloses the missing section: it belongs to the
176        /// document root's scope (including a missing `h1` title), or the
177        /// sugar's single-`h1` voice reports its `sections` scope as the
178        /// document's.
179        parent: HeaderPath,
180        /// Label of the unsatisfied schema matcher: exact text, a glob, a
181        /// slash-delimited regex, or `*`. This is schema text, not a heading.
182        matcher: String,
183    },
184    /// The document as a whole, when no single header can name the violation.
185    ///
186    /// Used for the document root's scope, which has no parent header, and for
187    /// the sugar's single-`h1` document voice described by specification §6.2.
188    Document,
189    /// A frontmatter block, or a value inside one. Has no header path.
190    Frontmatter {
191        /// The offending block, absent only when the document has none.
192        block: Option<FrontmatterBlock>,
193    },
194}
195
196/// The frontmatter block a diagnostic is about, and the value within it.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct FrontmatterBlock {
199    /// One-based inclusive line range of the complete frontmatter block.
200    pub line_range: FrontmatterLineRange,
201    /// JSON Pointer of a value rejected by JSON Schema, when applicable.
202    pub json_pointer: Option<String>,
203}
204
205/// One validation violation, with both document and schema-side anchors.
206#[derive(Debug, Clone, PartialEq, Eq)]
207#[non_exhaustive]
208pub struct Diagnostic {
209    /// Stable diagnostic category.
210    pub id: DiagnosticId,
211    /// What the diagnostic is about: a header, a missing one, the document, or
212    /// frontmatter.
213    pub target: DiagnosticTarget,
214    /// Primary Markdown source anchor.
215    pub location: DiagnosticLocation,
216    /// Structural schema node responsible for the diagnostic, when one exists.
217    pub schema_node: Option<SchemaNode>,
218    /// Concrete headers participating in a constraint violation.
219    pub involved_headers: Vec<InvolvedHeader>,
220    /// Normalized references participating in a constraint violation.
221    pub references: Vec<DiagnosticReference>,
222    /// Human-readable context; callers should key behavior on [`Self::id`].
223    pub message: String,
224}
225
226/// One-based inclusive line range.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
228pub struct FrontmatterLineRange {
229    /// First line covered by the range.
230    pub start_line: u64,
231    /// Last line covered by the range.
232    pub end_line: u64,
233}
234
235/// Failure to prepare a reusable validator from a semantic schema.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct PrepareValidationError {
238    /// Human-readable compilation failure.
239    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
250/// A schema compiled once for validating any number of documents.
251pub struct PreparedValidator {
252    schema: Schema,
253    plan: ValidationPlan,
254}
255
256impl PreparedValidator {
257    /// Compiles matchers and the immutable JSON Schema resource registry.
258    ///
259    /// Callers should pass a [`Schema`] produced by the loader. Preparation is
260    /// not a substitute for the loader's semantic checks when a schema has
261    /// been assembled manually from its public fields.
262    ///
263    /// # Errors
264    ///
265    /// Returns an error if a matcher or frontmatter JSON Schema cannot be compiled.
266    /// A schema returned by the loader has already passed equivalent checks,
267    /// but preparation retains a defensive failure path rather than assuming
268    /// every caller obtained the value from that boundary.
269    pub fn new(schema: &Schema) -> Result<Self, PrepareValidationError> {
270        Ok(Self {
271            schema: schema.clone(),
272            plan: ValidationPlan::new(schema)?,
273        })
274    }
275
276    /// Validates one parsed document without recompiling schema state.
277    ///
278    /// Frontmatter validation is included, and `fm.` propositions in
279    /// constraints evaluate against the document's frontmatter (§4.6).
280    ///
281    /// Diagnostic order is deterministic for a given schema and document but
282    /// follows the validation walk and is not a contract of this crate: a
283    /// refactor may reorder it between releases. Callers that promise an
284    /// output order must sort on diagnostic content, as the CLI does with a
285    /// documented total key.
286    pub fn validate(&self, document: &Document) -> Vec<Diagnostic> {
287        Validator::new(&self.schema, document).run(&self.plan)
288    }
289}
290
291/// Prepares and validates one document.
292///
293/// Use [`PreparedValidator`] directly when validating multiple documents.
294/// Diagnostic order is deterministic but not a contract; see
295/// [`PreparedValidator::validate`].
296///
297/// # Example
298///
299/// ```
300/// use outlint_core::{load_schema, parse_markdown, validate, MarkdownOptions};
301///
302/// let loaded = load_schema("version: 1\ntitle: '*'\nsections: []\n")?;
303/// let document = parse_markdown("# Guide\n", MarkdownOptions::default());
304/// let diagnostics = validate(&loaded.schema, &document)
305///     .expect("loaded schema matchers compile");
306///
307/// assert!(diagnostics.is_empty());
308/// # Ok::<(), outlint_core::InvalidSchema>(())
309/// ```
310pub 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    // This is the second place a frontmatter schema graph is compiled, and compiling a
345    // reference chain costs a stack frame per link, so the budget is charged
346    // here too rather than trusted to have been charged upstream. Today the
347    // loader is the only constructor of a `FrontmatterSchema` and refuses the
348    // same graphs, but a compile that overruns the stack aborts the process
349    // instead of returning, which is not a failure a later caller can recover
350    // from — so the check belongs at the call, not at the one path into it.
351    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(&registry)
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        // The document root is a virtual level-0 header enclosing the whole
515        // document: outline rules describe its `h1` children the way nested
516        // rules describe any header's children. When a sugar schema meets a
517        // document with no `h1`, the root stands in at level 1 — the
518        // `sections` scope then binds the document's own top-level `h2`s
519        // (alongside the missing-title the absent `h1` earns) — and
520        // `title: null` declares that shape outright, whatever the document
521        // contains.
522        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            // Structural and schema-independent: the walk covers the whole
529            // document, including subtrees the root never admits into any
530            // scope, and reporting a skipped level does not enroll a header
531            // in any rule. A top-level header deeper than the root's child
532            // level skips against the virtual root itself — the shape the
533            // retired `detached-section` diagnostic used to name.
534            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    /// Binds the general form's outline scope: `h1` rules on the virtual root.
552    ///
553    /// Ordinary scope semantics apply — the outline scope is open unless a
554    /// rule closes its own, an unmatched `h1` is nobody's business, and
555    /// top-level constraints attach here, targeting the document since the
556    /// virtual root has no header to name.
557    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    /// Binds a sugar schema's synthesized `h1` rule with its legacy voice.
594    ///
595    /// The sugar desugars to one required `h1` rule, but its diagnostics
596    /// predate the outline form and keep their spellings: the rule's absence
597    /// is `missing-title` anchored at [`SchemaNode::Title`], a mismatched
598    /// `h1` is `not-allowed` there rather than an ignored header, a surplus
599    /// `h1` is one `too-many-sections` on the second, and a lone `h1`'s
600    /// `sections` scope keeps reporting as the legacy root — cardinality
601    /// misses name no parent header and constraints target the document.
602    /// Each `h1` still binds its own child scope, so two same-named sections
603    /// under different `h1`s are budgeted per parent exactly as in every
604    /// nested scope — and when more than one `h1` binds, each instance's
605    /// diagnostics carry the owning `h1`'s path so the failing subtrees stay
606    /// apart.
607    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            // The headless scope: the virtual root stands in at level 1 and
622            // the `sections` rules bind the document's top-level `h2`s.
623            // One scope instance, so the legacy document voice is unambiguous.
624            // Bare `sections:` implies `title: "*"`, so a headless document
625            // is missing its title there exactly as under a spelled title.
626            if provenance != OutlineProvenance::NoTitle {
627                self.emit(
628                    Diagnostic {
629                        id: DiagnosticId::MissingTitle,
630                        // A missing `h1` belongs to the document root's scope,
631                        // whose virtual parent has no header path.
632                        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                // `title: null` desugars to a denied `h1` rule: a present
648                // `h1` is rejected wholesale, its subtree validated no
649                // further, like any header a deny rule matches.
650                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        // The titled scope: every `h1` occupies the synthesized rule, a
669        // mismatch reporting as a wrong title rather than dropping the header
670        // — its children are still the document's real structure and are
671        // still validated. Deeper top-level headers join only when skipped
672        // levels are allowed, and never as the title: the title rule is an
673        // `h1` rule, so only `h1`s occupy it or count against its one-title
674        // bound. An admitted deeper header instead binds into the `sections`
675        // scope — the title rule's child scope — like any skipped child of a
676        // bound header.
677        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                // Only a spelled title matcher can miss: the bare-sections
683                // any-text matcher accepts every `h1`.
684                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                // A top-level header deeper than `h1` can only precede the
696                // document's first `h1` — any later one nests under an `h1`
697                // in the parse tree — so joining the first instance below
698                // keeps document order.
699                admitted_strays.push(PathedSection {
700                    section: pathed.section,
701                    path: pathed.path.clone(),
702                });
703            }
704        }
705        // One diagnostic per document, anchored on the second occurrence in
706        // document order: that is where the bound breaks, and further surplus
707        // says nothing new. Every `h1` is the title — spelled or implied —
708        // so the title node takes the blame either way.
709        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        // The instance voice depends on how many `h1`s bound the rule. A
725        // single-`h1` sugar document reads as "the document" — the legacy
726        // root voice, with cardinality misses naming no parent and
727        // constraints targeting the document — and that voice is a corpus
728        // compatibility gate. With more than one `h1` the same voice would
729        // collapse two failing subtrees into byte-identical diagnostics, so
730        // each occurrence's diagnostics then carry the owning `h1`: the full
731        // path saying which subtree failed.
732        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    /// Binds one instance of a sugar schema's `sections` scope.
746    ///
747    /// With no `owner`, the scope reports as the legacy root: no parent
748    /// header for cardinality misses, the document as constraint target, and
749    /// the empty schema scope — which is the public address of the `sections`
750    /// list. With an `owner` — one `h1` of several, where the legacy voice
751    /// would repeat itself verbatim per subtree — cardinality misses name the
752    /// owning `h1` as their parent, and constraints target and anchor on it.
753    /// The schema scope stays the empty path either way: which instance bound
754    /// the rules does not move where the rules live. Section paths always
755    /// carry their real ancestor chain, enclosing `h1` included.
756    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                // `$.` refs in a sugar schema resolve against the `sections`
784                // scope, as they always have — here, this instance of it.
785                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                // jsonschema's serde_json backend accepts `&Value`; keep the
844                // public document model narrower and wrap it only at this boundary.
845                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                    // The root pointer names the mapping, whose extent is the
853                    // block; only a pointer into it can name a narrower anchor.
854                    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    /// Emits a diagnostic about a frontmatter block as a whole.
868    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    /// Emits a frontmatter diagnostic anchored at `anchor`, when one is known.
879    ///
880    /// The range stays the block's: the diagnostic concerns the block, and only
881    /// the point a reader is sent to narrows to the offending entry.
882    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    /// Reports every heading more than one level below its parent.
924    ///
925    /// `parent_level` is the enclosing header's level, or the virtual document
926    /// root's stand-in level for the top of the forest: 0 in general, 1 when a
927    /// sugar or `title: null` schema binds a headless document's `h2`s
928    /// directly. A top-level header deeper than `parent_level + 1` therefore
929    /// skips against the document root itself — the shape the retired
930    /// `detached-section` diagnostic used to name — and, exactly like that
931    /// predecessor, it takes part in no rule unless `allow_skipped_levels`
932    /// admits it into the root's scope.
933    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, &section.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(&section.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(&section.heading),
953                    true,
954                );
955            }
956            self.validate_skipped_levels(&section.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            // Already the section's complete ancestor chain. Do not rebuild it
976            // from the diagnostic attribution path, which is intentionally
977            // empty under the sugar's single-`h1` document voice.
978            let path = pathed.path.clone();
979            let matched = rules
980                .iter()
981                .zip(prepared_rules)
982                .enumerate()
983                .find(|(_, (_, prepared))| prepared.matcher.matches(&section.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                        &section.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                    &section.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(&section.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    /// Checks an ordered scope: every header an earlier accepting rule
1067    /// matched must precede every header a later one matched (§3.7).
1068    ///
1069    /// The check is §5.1's `last(A) < first(B)` over adjacent pairs of the
1070    /// scope's accepting rules that matched anything, in list order. Denied
1071    /// rules do not participate in the order pairing, while unmatched headers
1072    /// are unconstrained by ordering. Each violated pair is one `ordered`
1073    /// diagnostic, so that a
1074    /// misplaced section is named by the neighbours it broke rather than by
1075    /// the whole scope at once.
1076    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                    // No concrete header represents the unmet cardinality —
1175                    // matching headers may exist, just too few of them — so the
1176                    // last segment can only be the rule's matcher label.
1177                    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                    // The scope the constraint is attached to. The virtual
1249                    // document root has no header path; the sugar's single-h1
1250                    // voice likewise attributes its sections scope to the
1251                    // document (§6.2).
1252                    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    /// Emits a diagnostic about a header that is present in the document.
1295    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/// A document section paired with its complete document-tree path.
1343#[derive(Debug)]
1344struct PathedSection<'d> {
1345    section: &'d Section,
1346    path: HeaderPath,
1347}
1348
1349/// The document's top-level section forest — the virtual root's children —
1350/// each with the one-segment path that names it.
1351fn top_level_sections(sections: &[Section]) -> Vec<PathedSection<'_>> {
1352    sections
1353        .iter()
1354        .map(|section| PathedSection {
1355            section,
1356            path: appended_path(&HeaderPath::default(), &section.heading.diagnostic_text),
1357        })
1358        .collect()
1359}
1360
1361/// The virtual root's bindable children.
1362///
1363/// `child_level` is the level the root's scope describes: `h1` for the outline
1364/// scope, `h2` when a sugar or `title: null` schema binds a headless
1365/// document's `sections` scope directly. A deeper top-level header skips
1366/// levels against the virtual root; it binds into the root's scope only when
1367/// `allow_skipped_levels` says so, and otherwise takes part in nothing — the
1368/// preserved half of the retired detached-section semantics, with the
1369/// skipped-level walk speaking about it. (Inside a *bound* scope, children
1370/// bind whatever their level, as they always have; the virtual root differs
1371/// because an unadmitted top-level subtree has no bound ancestor at all.)
1372fn 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
1453/// Evaluates an `fm.` proposition against the document's frontmatter (§4.6).
1454///
1455/// The bare form is satisfied iff the addressed value exists and is not null —
1456/// mappings and sequences included. The `=` form additionally requires typed
1457/// scalar equality, so it is never satisfied by a mapping or sequence value.
1458fn 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
1482/// Typed equality between a frontmatter value and a resolved ref literal.
1483///
1484/// Both sides went through the YAML 1.2 core-schema resolver
1485/// ([`parse_frontmatter_scalar`]): the document side when the frontmatter was
1486/// read, the literal when the schema was loaded. Equality requires the same
1487/// type and the same value — `1` matches neither `"1"` nor `1.0`. Document
1488/// numbers keep their source lexeme (arbitrary precision), so re-resolving
1489/// that lexeme yields the canonical form the literal already carries.
1490fn 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        // Null never reaches here (the bare form already rejected it), and a
1511        // mapping or sequence is unsatisfied by every `=` form.
1512        _ => 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    /// The document's frontmatter mapping, when one parsed. `fm.` propositions
1523    /// address the document rather than a scope, so this is the same from
1524    /// every constraint node.
1525    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        // Same rule, same matcher, two different enclosing headers: the paths
1922        // distinguish them because the enclosing `h1` is kept, and each `h1`
1923        // binds its own `sections` scope. Two `h1` headers also break the
1924        // sugar's one-title bound, so the shape that makes the paths differ
1925        // is itself reported.
1926        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        // One `h1` above any number of root sections is the intended shape.
1967        assert!(surplus_diagnostics(schema, "# One\n## Overview\n## Overview\n").is_empty());
1968        // No `h1` misses the implied title, but that is not a surplus: the
1969        // `sections` scope still binds the document's own top-level `h2`s.
1970        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        // Anchored on the second `h1`, where the one-title bound breaks.
1976        assert_eq!(
1977            diagnostic.target,
1978            DiagnosticTarget::Header(HeaderPath(vec!["Two".into()]))
1979        );
1980        assert_eq!(diagnostic.location.line, 3);
1981        // Bare `sections:` implies `title: "*"`, so the implied title takes
1982        // the blame even though no `title:` key is spelled.
1983        assert_eq!(diagnostic.schema_node, Some(SchemaNode::Title));
1984
1985        // Surplus beyond the second header says nothing new.
1986        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        // Bounding the `h1` count is not enough on its own: this document has
1999        // exactly one `h1`, yet the leading `h2` precedes it with an empty
2000        // ancestor chain while the trailing one sits under it. The leading
2001        // one is a level skip against the virtual level-0 document root —
2002        // what `detached-section` used to name.
2003        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        // The skip is structural, so nothing in the schema is to blame — not
2013        // even when the schema names the `h1` with `title:`.
2014        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        // Every `h2` under the one `h1` conforms, and so does a document that
2025        // has no `h1` at all: the virtual root then stands in at level 1.
2026        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        // Each stray top-level header is its own misplacement, so each is
2030        // reported.
2031        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        // A stray header carries its own inline suppression, and the file
2043        // suppression covers them all.
2044        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        // The stray `h2` neither satisfies the rule it would match nor
2069        // withdraws the requirement: the `sections` scope binds the `h1`'s
2070        // children, and none of them is a `Detached`.
2071        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        // Nor does it count toward a maximum: one `Overview` is in scope, and
2092        // one is what the rule allows.
2093        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        // A header that should not be there cannot meaningfully be missing a
2108        // child, so nothing below the unadmitted root is bound; the skip walk
2109        // still descends, and finds `Surprise` one level under `X`, which is
2110        // no skip at all.
2111        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        // Stray *siblings* are independent misplacements with separate
2123        // fixes, so they stay one diagnostic each.
2124        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        // An orphan has no parent header; the virtual document root is what
2147        // it skips against — level 0 when the document has an `h1`.
2148        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        // With `title: null` the root stands in at level 1 and the `h2`s
2157        // bind directly, so a deeper orphan skips just the same.
2158        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        // A document of nothing but orphans reports each top-level one; the
2168        // `h4` one level under its `h3` parent is no skip of its own.
2169        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        // Structural admission is not a second gate on rule matching: a
2187        // bound scope's header that matches no rule is the business of
2188        // `strict`, which stays opt-in.
2189        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        // General form, virtual root at level 0: an `h2` at the top skips a
2219        // level. With the option off it is reported and takes part in
2220        // nothing; with it on it binds into the outline scope like any
2221        // skipped child of a bound header, and can satisfy an h1 rule.
2222        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        // Sugar's headless scope stands in at level 1, one level down: a
2244        // top-level `h3` is the skip there, and admission works the same.
2245        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        // The declared shape: no h1, the sections scope is the document's
2273        // own top-level h2s.
2274        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        // A present h1 is rejected wholesale at the title node, its subtree
2287        // validated no further — like any header a deny rule matches. The
2288        // top-level h2 before it still binds.
2289        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        // `sections:` without `title:` means `title: "*"`: exactly one `h1`,
2311        // any text. A document that loses its `# Title` no longer passes
2312        // silently.
2313        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        // With no `title:` key to blame, the title node anchors on the
2330        // `sections` entry — the spelling that implied the rule.
2331        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        // A single `h1` — any text — satisfies the implied title, and the
2341        // same headless document under `title: null` is declared conformant.
2342        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        // The strictness is sugar business: the general form has no title
2347        // slot, so a zero-`h1` document under `outline:` misses nothing.
2348        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        // No bespoke wrong-title verdict in the general form: an unmatched h1
2357        // is simply not this schema's business unless a rule or `strict`
2358        // makes it so, and the required rule reports its own absence.
2359        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        // Two failing `h1` subtrees under the legacy document voice would be
2375        // byte-identical; with more than one bound `h1` each instance's
2376        // diagnostics name their owner instead, so both parents appear.
2377        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        // A single bound `h1` keeps the exact legacy voice: no parent header
2405        // on the miss. That voice is pinned corpus-wide; this is the local
2406        // witness that the attribution switch is the occurrence count.
2407        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        // One `h1`: the legacy voice, the document as target.
2428        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        // Two `h1`s, both violating: each violation targets and anchors on
2437        // its own `h1` header instead of naming the document twice.
2438        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        // MAJOR-2 ruling: only `h1`s count for the title. With skipped levels
2457        // allowed, a leading `h2` that the title matcher would accept used to
2458        // consume the one-title bound — every leading `h2` under `title: "*"`
2459        // — yielding a phantom surplus title plus a missing section. It now
2460        // binds into the `sections` scope instead, where `Overview` under the
2461        // real `h1` and the unmatched `Intro` are both ordinary open-scope
2462        // members.
2463        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        // The stray is not merely excluded from the title slot — it joins the
2474        // `sections` scope and can satisfy its rules. This pins the ruled
2475        // behavior against both regressions at once: under the old counting,
2476        // `Intro` matches `*` and occupies the title slot (surplus title plus
2477        // two missing-`Intro` instances); were the stray dropped outright,
2478        // the required `Intro` rule would fire. Only binding into the
2479        // `sections` scope leaves the document clean.
2480        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        // Without `title:` the identical document reads the same way: bare
2497        // `sections:` implies `title: "*"`, so the surplus `h1` is a surplus
2498        // title there too, blamed on the implied title node.
2499        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        // Under the sugar's single-h1 voice, the sections scope is attributed
2531        // to the document; a missing section still has its schema-side matcher
2532        // label.
2533        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        // Preparing a validator compiles the linked graph a second time, and
2760        // compiling a reference re-enters the compiler at its target, so a
2761        // chain costs a stack frame per link here exactly as it does in the
2762        // loader -- while every link sits at the same JSON depth, which is why
2763        // no nesting bound sees it. An overrun aborts the process rather than
2764        // returning, so this path cannot rely on the loader having refused
2765        // first; it charges the budget itself. Both sides of the boundary are
2766        // pinned, since a bound that quietly drifted below what it promises
2767        // would refuse graphs the compiler handles comfortably.
2768        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    /// Builds a graph whose root reference starts a chain of `links` hops
2788    /// ending at `true`, declaring `links + 1` references in all.
2789    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    /// Builds an `fm.` reference the way the loader normalizes one: the
2808    /// equality literal resolves through the shared core-schema resolver.
2809    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    /// Evaluates one `fm.` proposition against a Markdown document's parsed
2825    /// frontmatter, typed by the real reader.
2826    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        // A null value does not satisfy the bare form, and neither does a key
2840        // the frontmatter lacks.
2841        assert!(!fm_satisfied(document, &["empty"], None, false));
2842        assert!(!fm_satisfied(document, &["absent"], None, false));
2843        // Nested steps address nested mappings.
2844        assert!(fm_satisfied(document, &["nested", "inner"], None, false));
2845        assert!(!fm_satisfied(document, &["nested", "missing"], None, false));
2846        // A step into a non-mapping is unsatisfied, whatever the value is.
2847        assert!(!fm_satisfied(document, &["present", "deeper"], None, false));
2848        // A document with no frontmatter at all satisfies nothing.
2849        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        // The bare form is satisfied by any non-null value, collections
2856        // included; the `=` form compares scalars only.
2857        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        // Stepping through a sequence is unsatisfied: only mappings nest.
2862        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        // There is no quoting in the ref literal: the quotes are characters
2871        // of the string, which the value `"1"` does not contain.
2872        assert!(!fm_satisfied(document, &["spelled"], Some("\"1\""), false));
2873        // The spec's three negative examples: no cross-type coercion.
2874        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        // Both sides canonicalize before comparing: spelling is irrelevant
2878        // within a type.
2879        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        // `=null` can never hold: a null value already fails the bare form.
2884        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        // Unicode simple folding: `ſ` matches `S` only case-insensitively.
2914        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        // The top-level sugar constraint binds the title's sections scope but
2960        // uses the single-h1 document voice; the frontmatter side is named
2961        // among the references.
2962        assert_eq!(diagnostic.target, DiagnosticTarget::Document);
2963        assert_eq!(
2964            diagnostic.references[0],
2965            DiagnosticReference::Frontmatter(fm_reference(&["status"], Some("deprecated"))),
2966        );
2967
2968        // Unsatisfied condition: nothing fires.
2969        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        // Satisfied consequence: nothing fires either.
2978        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        // A nested rule id `fm` with child `x` would make the rule path
2990        // `fm.x` spellable — but `fm.` refs resolve via the frontmatter slot,
2991        // never the rule forest, so the headers below cannot satisfy the
2992        // condition.
2993        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        // Headers satisfy the rule path fm -> x in the constraint's scope; the
3002        // frontmatter key `x` is absent. Were the ref a rule ref, the
3003        // condition would hold and the unsatisfiable consequence would fire.
3004        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        // The frontmatter key alone fires it, with no matching header in sight.
3013        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        // No constraint spelled: the `sections` list is the order. Under the
3035        // sugar's document voice the violation targets the document and is
3036        // attributed to the title node that owns the sections scope; the
3037        // message names the pair that broke.
3038        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        // Involved headers are the two rules' occurrences in document order.
3056        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        // `last(A) < first(B)` over adjacent present rules, one diagnostic per
3072        // broken pair: a fully reversed list breaks every pair, while a
3073        // single displaced section breaks only the pairs around it.
3074        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        // Unmatched headers in an open scope are unconstrained by ordering; a
3097        // denied rule contributes no accepted occurrence to the order; an
3098        // absent optional rule is simply not among the present pairs.
3099        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        // Repeats of one rule may sit together but not straddle the next
3116        // rule's occurrences: every A precedes every B.
3117        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        // A nested scope's violation targets the owning header and is
3131        // attributed to the owning rule; the general form's outline scope
3132        // targets the document and has no schema node, the root being
3133        // nobody's rule.
3134        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        // The rule's own `ordered` wins in both directions.
3163        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        // Two h1s under the sugar bind two instances; each is compared on
3178        // its own and names its owning h1, since the document voice would
3179        // otherwise emit indistinguishable duplicates.
3180        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        // The constraint path resolves refs by id rather than walking rule
3205        // indices, so it is tested on its own: `last(A) < first(B)` over
3206        // every occurrence, in an unordered scope where only the constraint
3207        // speaks.
3208        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        // The constraint form cites its refs, unlike the implicit form.
3224        assert_eq!(diagnostics[0].references.len(), 2);
3225        // Unordered scope: the reverse order is legal once the constraint
3226        // says so, which the implicit form could never express.
3227        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        // Attached to the sugar `sections` scope, the constraint is evaluated
3238        // once per enclosing h1. An inversion inside one part fires and names
3239        // that part; the same pair split across two parts leaves each
3240        // instance holding one ref, vacuously satisfied.
3241        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}