Skip to main content

outlint_core/
loader.rs

1//! Loading, semantic validation, and normalization of Outlint schemas.
2//!
3//! `InvalidSchema` intentionally owns all source and error data, so boxing it
4//! merely to reduce the result enum would complicate the public loader API.
5
6#![allow(clippy::result_large_err)]
7
8use std::{
9    borrow::Cow,
10    collections::{BTreeMap, HashMap, HashSet},
11    sync::Arc,
12};
13
14use num_bigint::{BigInt, BigUint};
15use saphyr_parser::{
16    Event as YamlEvent, Parser as YamlParser, ScalarStyle, Span, StrInput, Tag as YamlTag,
17};
18use serde::Deserialize;
19use serde_json::Value;
20use unicode_normalization::{char::is_combining_mark, UnicodeNormalization};
21
22use crate::markdown::{
23    deeper_yaml_nesting, exact_yaml_scalar_to_json, validate_yaml_container_tag, ExactYamlBudget,
24    ExactYamlScalar, YamlValueError,
25};
26use crate::matcher::{compile_anchored_pattern, compile_glob_pattern};
27use crate::{
28    AtLeastTwo, ByteOffset, CanonicalFloat, CanonicalInteger, Cardinality, Constraint,
29    ConstraintIndex, ConstraintPath, ExactText, FrontmatterKey, FrontmatterPolicy, FrontmatterRef,
30    FrontmatterScalar, GlobPattern, InvalidSchema, JsonSchemaResourceContents,
31    LinkedJsonSchemaInput, LoadSchemaResult, LoadedSchema, Matcher, NonEmpty, Options,
32    OutlineProvenance, Proposition, RefAnchor, RegexPattern, RelatedLocation, RuleId, RuleIndex,
33    RuleOutcome, RulePath, RuleRef, Schema, SchemaError, SchemaErrorKind, SchemaLocations,
34    SchemaNode, SchemaSource, SchemaSources, SchemaVersion, ScopePath, SectionRule, SourceId,
35    SourceLabel, SourceRange, TextRange, UpperBound,
36};
37
38/// The object domain schema documents are validated in: JSON Schema's own.
39type JsonMap = serde_json::Map<String, Value>;
40
41/// Loads an Outlint schema from UTF-8 source text.
42///
43/// The returned model contains only normalized values. Errors are accumulated
44/// where later checks do not depend on an earlier invalid value.
45///
46/// # Example
47///
48/// ```
49/// use outlint_core::{load_schema, Matcher};
50///
51/// let loaded = load_schema(
52///     r#"
53/// version: 1
54/// title: null
55/// sections:
56///   - match: Overview
57/// "#,
58/// )?;
59///
60/// assert!(matches!(
61///     loaded.schema.outline[0].sections[0].matcher,
62///     Matcher::Exact(_)
63/// ));
64/// # Ok::<(), outlint_core::InvalidSchema>(())
65/// ```
66pub fn load_schema(source: &str) -> LoadSchemaResult {
67    load_schema_with_label(source, None)
68}
69
70/// Loads an Outlint schema from source text with a diagnostic display label.
71pub fn load_schema_with_label(source: &str, label: Option<SourceLabel>) -> LoadSchemaResult {
72    Loader::new(Arc::from(source), label, None).load()
73}
74
75/// Loads an Outlint schema with an already-preloaded linked JSON Schema graph.
76///
77/// This is the complete pure loader boundary for filesystem-backed schemas:
78/// callers perform all reads first and provide stable logical resource URIs.
79pub fn load_schema_with_resources(
80    source: &str,
81    label: Option<SourceLabel>,
82    external: Option<LinkedJsonSchemaInput>,
83) -> LoadSchemaResult {
84    Loader::new(Arc::from(source), label, external).load()
85}
86
87#[derive(Debug)]
88struct PreparedExternalSchema {
89    root_source: SourceId,
90    result: Result<crate::FrontmatterSchema, NonEmpty<PreparedExternalError>>,
91}
92
93#[derive(Debug)]
94struct PreparedExternalError {
95    source: SourceId,
96    message: String,
97}
98
99/// Returns the linked frontmatter schema path declared by valid outer YAML.
100///
101/// The path is returned exactly as declared. Resolution against a lexical file
102/// location belongs to the I/O shell.
103pub fn linked_frontmatter_schema_path(source: &str) -> Option<String> {
104    let value = schema_yaml_to_json(parse_schema_yaml(source).ok()?).ok()?;
105    value
106        .as_object()?
107        .get("frontmatter")?
108        .as_object()?
109        .get("schema")?
110        .as_str()
111        .map(str::to_owned)
112}
113
114/// Finds external documents referenced from one draft 2020-12 resource.
115///
116/// Each reference is resolved both from the resource's lexical physical URI,
117/// without applying `$id`, and from its logical URI according to JSON Schema
118/// `$id` semantics. Returned URIs have fragments removed and preserve
119/// traversal order, including same-document references and duplicates.
120pub fn json_schema_external_references(
121    source: &str,
122    physical_base_uri: &str,
123    logical_base_uri: &str,
124) -> Result<Vec<crate::JsonSchemaExternalReference>, String> {
125    let value: serde_json::Value = serde_json::from_str(source)
126        .map_err(|error| format!("invalid JSON Schema document: {error}"))?;
127    let physical_base = jsonschema::Uri::parse(physical_base_uri.to_owned()).map_err(|error| {
128        format!("invalid physical JSON Schema base URI `{physical_base_uri}`: {error:?}")
129    })?;
130    let logical_base = jsonschema::Uri::parse(logical_base_uri.to_owned()).map_err(|error| {
131        format!("invalid logical JSON Schema base URI `{logical_base_uri}`: {error:?}")
132    })?;
133    let mut references = Vec::new();
134    collect_external_references(&value, &physical_base, &logical_base, &mut references)?;
135    Ok(references)
136}
137
138fn collect_external_references(
139    value: &serde_json::Value,
140    physical_base: &jsonschema::Uri<String>,
141    inherited_logical_base: &jsonschema::Uri<String>,
142    references: &mut Vec<crate::JsonSchemaExternalReference>,
143) -> Result<(), String> {
144    let mut logical_base = inherited_logical_base.clone();
145    if let Some(identifier) = value
146        .as_object()
147        .and_then(|object| object.get("$id"))
148        .and_then(serde_json::Value::as_str)
149    {
150        logical_base = jsonschema::uri::resolve_against(&logical_base.borrow(), identifier)
151            .map_err(|error| format!("invalid JSON Schema `$id` `{identifier}`: {error}"))?;
152    }
153    if let Some(object) = value.as_object() {
154        for keyword in ["$ref", "$dynamicRef"] {
155            if let Some(reference) = object.get(keyword).and_then(serde_json::Value::as_str) {
156                let physical = jsonschema::uri::resolve_against(&physical_base.borrow(), reference)
157                    .map_err(|error| {
158                        format!("invalid JSON Schema `{keyword}` `{reference}`: {error}")
159                    })?;
160                let logical = jsonschema::uri::resolve_against(&logical_base.borrow(), reference)
161                    .map_err(|error| {
162                    format!("invalid JSON Schema `{keyword}` `{reference}`: {error}")
163                })?;
164                let physical_uri = physical.as_str().split('#').next().unwrap_or_default();
165                let logical_uri = logical.as_str().split('#').next().unwrap_or_default();
166                if !physical_uri.is_empty() && !logical_uri.is_empty() {
167                    references.push(crate::JsonSchemaExternalReference {
168                        physical_uri: physical_uri.to_owned(),
169                        logical_uri: logical_uri.to_owned(),
170                    });
171                }
172            }
173        }
174    }
175    for child in jsonschema::Draft::Draft202012.subresources_of(value) {
176        collect_external_references(child, physical_base, &logical_base, references)?;
177    }
178    Ok(())
179}
180
181/// Refuses resolution outside an immutable preloaded registry.
182#[derive(Debug)]
183pub(crate) struct NoExternalRetrieve;
184
185impl jsonschema::Retrieve for NoExternalRetrieve {
186    fn retrieve(
187        &self,
188        uri: &jsonschema::Uri<String>,
189    ) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
190        Err(format!("JSON Schema resource `{uri}` was not preloaded").into())
191    }
192}
193
194/// Starts a registry that can resolve only resources supplied by the caller.
195pub(crate) fn preloaded_json_schema_registry<'a>() -> jsonschema::RegistryBuilder<'a> {
196    jsonschema::Registry::new().retriever(NoExternalRetrieve)
197}
198
199fn prepare_external_schema(
200    input: &LinkedJsonSchemaInput,
201    source_ids: &BTreeMap<String, SourceId>,
202) -> PreparedExternalSchema {
203    let root_source = source_ids
204        .get(&input.root_uri)
205        .copied()
206        .unwrap_or(SourceId(0));
207    let result = prepare_external_schema_result(input, source_ids);
208    PreparedExternalSchema {
209        root_source,
210        result,
211    }
212}
213
214fn prepare_external_schema_result(
215    input: &LinkedJsonSchemaInput,
216    source_ids: &BTreeMap<String, SourceId>,
217) -> Result<crate::FrontmatterSchema, NonEmpty<PreparedExternalError>> {
218    let mut parsed = BTreeMap::new();
219    let mut seen = HashSet::new();
220    let mut errors = Vec::new();
221    let mut references = 0usize;
222    for (index, resource) in input.resources.iter().enumerate() {
223        let source = external_source_id(index).unwrap_or(SourceId(0));
224        if !seen.insert(resource.uri.clone()) {
225            errors.push(PreparedExternalError {
226                source,
227                message: format!("duplicate JSON Schema resource URI `{}`", resource.uri),
228            });
229            continue;
230        }
231        let text = match &resource.contents {
232            JsonSchemaResourceContents::Loaded(text) => text,
233            JsonSchemaResourceContents::ReadFailure(message) => {
234                errors.push(PreparedExternalError {
235                    source,
236                    message: message.clone(),
237                });
238                continue;
239            }
240        };
241        let value: serde_json::Value = match serde_json::from_str(text) {
242            Ok(value) => value,
243            Err(error) => {
244                errors.push(PreparedExternalError {
245                    source,
246                    message: format!("invalid linked JSON Schema document: {error}"),
247                });
248                continue;
249            }
250        };
251        if let Err(message) = validate_json_schema_document(&value) {
252            errors.push(PreparedExternalError { source, message });
253            continue;
254        }
255        // The budget spans the graph rather than any one document, so it is
256        // charged as the documents arrive and reported against the one that
257        // spends the last of it. Nothing later can bring the total back under,
258        // and an over-budget graph is never compiled, so stop reading here
259        // instead of listing local faults in resources that will not be used.
260        references = references.saturating_add(json_schema_reference_count(&value));
261        if references > MAX_JSON_SCHEMA_REFERENCES {
262            errors.push(PreparedExternalError {
263                source,
264                message: json_schema_reference_budget_message(),
265            });
266            break;
267        }
268        parsed.insert(resource.uri.clone(), value);
269    }
270    // Resource-local failures are independent and retain input order. The
271    // registry is graph-dependent, so do not compile an incomplete graph or
272    // add cascading resolution failures after any local error.
273    if let Some(errors) = non_empty(errors) {
274        return Err(errors);
275    }
276    let mut resources = parsed;
277    let root = resources.remove(&input.root_uri).ok_or_else(|| {
278        single_external_error(PreparedExternalError {
279            source: root_source_id(source_ids, &input.root_uri),
280            message: format!(
281                "linked JSON Schema root resource `{}` was not preloaded",
282                input.root_uri
283            ),
284        })
285    })?;
286
287    {
288        let mut registry = preloaded_json_schema_registry()
289            .add(input.root_uri.as_str(), &root)
290            .map_err(|error| {
291                single_external_error(external_registry_error(
292                    error.to_string(),
293                    source_ids,
294                    &input.root_uri,
295                ))
296            })?;
297        for (uri, resource) in &resources {
298            registry = registry.add(uri.as_str(), resource).map_err(|error| {
299                single_external_error(external_registry_error(error.to_string(), source_ids, uri))
300            })?;
301        }
302        let registry = registry.prepare().map_err(|error| {
303            single_external_error(external_registry_error(
304                error.to_string(),
305                source_ids,
306                &input.root_uri,
307            ))
308        })?;
309        jsonschema::draft202012::options()
310            .with_registry(&registry)
311            .with_base_uri(input.root_uri.clone())
312            .with_retriever(NoExternalRetrieve)
313            .build(&root)
314            .map_err(|error| {
315                let message = error.to_string();
316                // jsonschema does not expose a structured resource origin, so
317                // this attribution depends on its error wording containing a URI.
318                let source = source_ids
319                    .iter()
320                    .find_map(|(uri, source)| message.contains(uri).then_some(*source))
321                    .unwrap_or_else(|| root_source_id(source_ids, &input.root_uri));
322                single_external_error(PreparedExternalError {
323                    source,
324                    message: format!("cannot compile linked JSON Schema: {message}"),
325                })
326            })?;
327    }
328
329    Ok(crate::FrontmatterSchema {
330        root_uri: input.root_uri.clone(),
331        root,
332        resources,
333    })
334}
335
336/// Stable hierarchical identity for resolving relative `$id` values inline.
337///
338/// The reserved `.invalid` top-level domain cannot identify a retrievable
339/// resource, and inline reference values are checked before compilation, so
340/// this supplies URI hierarchy without opening an external loading path.
341const INLINE_FRONTMATTER_SCHEMA_URI: &str =
342    "https://outlint.invalid/inline/frontmatter.schema.json";
343
344fn prepare_inline_schema(mapping: JsonMap) -> Result<crate::FrontmatterSchema, NonEmpty<String>> {
345    let root = Value::Object(mapping);
346    let mut errors = invalid_inline_references(&root);
347    // A malformed reference is also rejected by the draft meta-schema, but
348    // the inline contract has a more specific rule and diagnostic. Avoid
349    // reporting both descriptions for the same keyword.
350    if errors.is_empty() {
351        if let Err(message) = validate_json_schema_document(&root) {
352            errors.push(message);
353        }
354    }
355    if json_schema_reference_count(&root) > MAX_JSON_SCHEMA_REFERENCES {
356        errors.push(json_schema_reference_budget_message());
357    }
358    if let Some(errors) = non_empty(errors) {
359        return Err(errors);
360    }
361
362    {
363        let registry = preloaded_json_schema_registry()
364            .add(INLINE_FRONTMATTER_SCHEMA_URI, &root)
365            .and_then(jsonschema::RegistryBuilder::prepare)
366            .map_err(|error| {
367                single_string_error(format!(
368                    "cannot prepare inline frontmatter JSON Schema: {error}"
369                ))
370            })?;
371        jsonschema::draft202012::options()
372            .with_registry(&registry)
373            .with_base_uri(INLINE_FRONTMATTER_SCHEMA_URI.to_owned())
374            .with_retriever(NoExternalRetrieve)
375            .build(&root)
376            .map_err(|error| {
377                single_string_error(format!(
378                    "cannot compile inline frontmatter JSON Schema: {error}"
379                ))
380            })?;
381    }
382
383    Ok(crate::FrontmatterSchema {
384        root_uri: INLINE_FRONTMATTER_SCHEMA_URI.into(),
385        root,
386        resources: BTreeMap::new(),
387    })
388}
389
390fn single_string_error(message: String) -> NonEmpty<String> {
391    NonEmpty {
392        first: message,
393        rest: Vec::new(),
394    }
395}
396
397fn invalid_inline_references(value: &Value) -> Vec<String> {
398    let mut errors = Vec::new();
399    walk_json_objects(value, |object| {
400        for keyword in ["$ref", "$dynamicRef"] {
401            if let Some(child) = object.get(keyword) {
402                match child.as_str() {
403                    Some(reference) if reference.starts_with('#') => {}
404                    Some(reference) => errors.push(format!(
405                        "inline frontmatter JSON Schema `{keyword}` must be fragment-only, found `{reference}`"
406                    )),
407                    None => errors.push(format!(
408                        "inline frontmatter JSON Schema `{keyword}` must be a string beginning with `#`"
409                    )),
410                }
411            }
412        }
413    });
414    errors
415}
416
417fn walk_json_objects(value: &Value, mut visit: impl FnMut(&JsonMap)) {
418    let mut pending = vec![value];
419    while let Some(value) = pending.pop() {
420        match value {
421            Value::Object(object) => {
422                visit(object);
423                pending.extend(object.values());
424            }
425            Value::Array(items) => pending.extend(items),
426            _ => {}
427        }
428    }
429}
430
431fn single_external_error(error: PreparedExternalError) -> NonEmpty<PreparedExternalError> {
432    NonEmpty {
433        first: error,
434        rest: Vec::new(),
435    }
436}
437
438/// How many reference-shaped members one frontmatter schema graph may declare.
439///
440/// Compiling a reference re-enters the compiler at the target, so a chain of
441/// references costs one stack frame per link however flat the documents that
442/// spell it are: every link of `{"$ref":"#/x/1"}, {"$ref":"#/x/2"}, ...` sits
443/// at the same JSON depth, which is why neither the YAML depth limit nor
444/// `serde_json`'s parse limit sees the chain at all. What the limit therefore
445/// has to bound is a count, not a nesting.
446///
447/// It counts occurrences rather than the longest chain because a chain's
448/// length is only knowable by resolving every reference the way the compiler
449/// does — through `$id`, `$anchor`, `$dynamicAnchor`, JSON pointers, and
450/// across resources — and a second implementation of that resolution would
451/// either refuse graphs the compiler handles or, worse, admit ones it cannot.
452/// A count needs no resolution and still bounds the recursion, since a stack
453/// path enters each evaluated reference member at most once: cycles are cut by
454/// the compiler's own pending-node cache, which is why a self-reference or a
455/// mutual pair compiles today rather than overflowing.
456///
457/// The value is the constant this crate already uses wherever structure has to
458/// be bounded, and which `serde_json` defaults to. Measured
459/// against the compiler, a link costs about 1.7 KB of stack optimized and
460/// about 6 KB unoptimized, so 128 links stay under a megabyte in the tightest
461/// configuration — an unoptimized build on a two-megabyte thread, where the
462/// abort begins around 345 links. Measured against real schemas it is far
463/// above anything one contains: the whole conformance corpus declares at most
464/// four references across a linked graph and its deepest chain is two.
465pub(crate) const MAX_JSON_SCHEMA_REFERENCES: usize = 128;
466
467/// Reports the shared wording for a graph that spends more than the budget.
468pub(crate) fn json_schema_reference_budget_message() -> String {
469    format!(
470        "frontmatter JSON Schema declares more than {MAX_JSON_SCHEMA_REFERENCES} \
471         `$ref` or `$dynamicRef` members"
472    )
473}
474
475/// Counts reserved `$ref` and `$dynamicRef` members in one JSON document.
476///
477/// The walk carries an explicit stack rather than recursing, so what it costs
478/// the call stack does not depend on the limit it enforces. A counter that
479/// recursed would be safe only while a limit's worth of its own frames still
480/// fit, which ties the choice of limit to the shape of the check and would
481/// turn a later raise of the limit into the very overflow being refused here.
482/// Those two member names are the keywords whose
483/// compilation re-enters the compiler under draft 2020-12, the only dialect
484/// [`validate_json_schema_document`] admits, and they are the same pair
485/// [`collect_external_references`] follows.
486///
487/// Every object member with either reserved name counts, including members in
488/// instance-shaped or otherwise unreachable data. A fragment JSON Pointer may
489/// turn any object into an evaluated schema, so limiting the walk to recognized
490/// Draft 2020-12 subresources would leave a hidden reference chain unbounded.
491pub(crate) fn json_schema_reference_count(value: &serde_json::Value) -> usize {
492    let mut references = 0usize;
493    walk_json_objects(value, |object| {
494        references = references.saturating_add(
495            usize::from(object.contains_key("$ref"))
496                + usize::from(object.contains_key("$dynamicRef")),
497        );
498    });
499    references
500}
501
502fn validate_json_schema_document(value: &serde_json::Value) -> Result<(), String> {
503    if !value.is_object() && !value.is_boolean() {
504        return Err("frontmatter JSON Schema root must be an object or boolean".into());
505    }
506    if let Some(dialect) = value.as_object().and_then(|object| object.get("$schema")) {
507        let supported = dialect.as_str().is_some_and(|dialect| {
508            matches!(
509                dialect,
510                "https://json-schema.org/draft/2020-12/schema"
511                    | "https://json-schema.org/draft/2020-12/schema#"
512            )
513        });
514        if !supported {
515            return Err(format!(
516                "unsupported JSON Schema dialect `{dialect}`; expected draft 2020-12"
517            ));
518        }
519    }
520    jsonschema::draft202012::meta::validate(value)
521        .map_err(|error| format!("invalid draft 2020-12 JSON Schema: {error}"))
522}
523
524fn root_source_id(source_ids: &BTreeMap<String, SourceId>, root_uri: &str) -> SourceId {
525    source_ids.get(root_uri).copied().unwrap_or(SourceId(0))
526}
527
528fn external_source_id(index: usize) -> Option<SourceId> {
529    u32::try_from(index)
530        .ok()
531        .and_then(|index| index.checked_add(1))
532        .map(SourceId)
533}
534
535fn external_registry_error(
536    message: String,
537    source_ids: &BTreeMap<String, SourceId>,
538    fallback_uri: &str,
539) -> PreparedExternalError {
540    // jsonschema exposes only display text here; source attribution therefore
541    // intentionally depends on the message retaining the resource URI.
542    let source = source_ids
543        .iter()
544        .find_map(|(uri, source)| message.contains(uri).then_some(*source))
545        .unwrap_or_else(|| root_source_id(source_ids, fallback_uri));
546    PreparedExternalError { source, message }
547}
548
549#[derive(Debug, Deserialize)]
550#[serde(deny_unknown_fields)]
551struct RawSchema {
552    version: i64,
553    title: Option<String>,
554    #[serde(default)]
555    options: RawOptions,
556    #[serde(default)]
557    frontmatter: Option<RawFrontmatter>,
558    /// Absent only when `outline` is declared; the shape validation enforces
559    /// exactly one of the two before this structure is built.
560    sections: Option<Vec<RawRule>>,
561    outline: Option<Vec<RawRule>>,
562    #[serde(default)]
563    constraints: Vec<Value>,
564}
565
566#[derive(Debug, Default, Deserialize)]
567#[serde(deny_unknown_fields)]
568struct RawFrontmatter {
569    required: Option<bool>,
570    allow: Option<bool>,
571    schema: Option<RawFrontmatterSchema>,
572}
573
574#[derive(Debug, Deserialize)]
575#[serde(untagged)]
576enum RawFrontmatterSchema {
577    Path(String),
578    Mapping(JsonMap),
579}
580
581#[derive(Debug, Default, Deserialize)]
582#[serde(deny_unknown_fields)]
583struct RawOptions {
584    match_case: Option<bool>,
585    strip_inline_markup: Option<bool>,
586    allow_skipped_levels: Option<bool>,
587    ordered_sections: Option<bool>,
588}
589
590#[derive(Debug, Deserialize)]
591#[serde(deny_unknown_fields)]
592struct RawRule {
593    id: Option<String>,
594    #[serde(rename = "match")]
595    matcher: String,
596    #[serde(default = "default_true")]
597    allow: bool,
598    required: Option<bool>,
599    repeat: Option<String>,
600    #[serde(default)]
601    strict: bool,
602    ordered: Option<bool>,
603    #[serde(default)]
604    sections: Vec<RawRule>,
605    #[serde(default)]
606    constraints: Vec<Value>,
607}
608
609const fn default_true() -> bool {
610    true
611}
612
613const DOCUMENT_FIELDS: &[&str] = &[
614    "version",
615    "title",
616    "options",
617    "frontmatter",
618    "outline",
619    "sections",
620    "constraints",
621];
622const OPTION_FIELDS: &[&str] = &[
623    "match_case",
624    "strip_inline_markup",
625    "allow_skipped_levels",
626    "ordered_sections",
627];
628const FRONTMATTER_FIELDS: &[&str] = &["required", "allow", "schema"];
629const RULE_FIELDS: &[&str] = &[
630    "id",
631    "match",
632    "allow",
633    "required",
634    "repeat",
635    "strict",
636    "ordered",
637    "sections",
638    "constraints",
639];
640
641#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
642enum RangeKey {
643    DocumentField(String),
644    OptionField(String),
645    FrontmatterField(String),
646    Rule(RulePath),
647    RuleField(RulePath, String),
648    Constraint(ConstraintPath),
649    /// An `h1`-level rule in the top-level `outline` list.
650    OutlineRule(RuleIndex),
651    /// One field of an `h1`-level rule in the top-level `outline` list.
652    OutlineRuleField(RuleIndex, String),
653}
654
655#[derive(Default)]
656struct RangeIndex {
657    ranges: BTreeMap<RangeKey, SourceRange>,
658}
659
660impl RangeIndex {
661    /// Reads every addressable range off the one tree the loader parsed.
662    ///
663    /// The walk mirrors the shape validation below: document fields, options,
664    /// frontmatter, and the rule and constraint forests. Lookups are linear
665    /// scans over each mapping's ordered entries, which is the right cost for
666    /// schema documents — a mapping here has a handful of keys, and the parse
667    /// has already rejected duplicates, so the first match is the only one.
668    fn from_tree(root: &SchemaYamlNode, char_offsets: &[usize]) -> Self {
669        let mut index = Self::default();
670        let Some(mapping) = root.as_mapping() else {
671            return index;
672        };
673        let expansion = subtree_expansion(root, None);
674        for &field in DOCUMENT_FIELDS {
675            if let Some(node) = schema_mapping_get(mapping, field) {
676                index.ranges.insert(
677                    RangeKey::DocumentField(field.into()),
678                    node_range(node, expansion, char_offsets),
679                );
680            }
681        }
682        for (section, fields) in [
683            ("options", OPTION_FIELDS),
684            ("frontmatter", FRONTMATTER_FIELDS),
685        ] {
686            let Some(node) = schema_mapping_get(mapping, section) else {
687                continue;
688            };
689            let expansion = subtree_expansion(node, expansion);
690            let Some(entries) = node.as_mapping() else {
691                continue;
692            };
693            for &field in fields {
694                if let Some(value) = schema_mapping_get(entries, field) {
695                    index.ranges.insert(
696                        match section {
697                            "options" => RangeKey::OptionField(field.into()),
698                            _ => RangeKey::FrontmatterField(field.into()),
699                        },
700                        node_range(value, expansion, char_offsets),
701                    );
702                }
703            }
704        }
705        if let Some(node) = schema_mapping_get(mapping, "sections") {
706            let expansion = subtree_expansion(node, expansion);
707            if let Some(sections) = node.as_sequence() {
708                index.collect_rules(sections, &ScopePath(Vec::new()), expansion, char_offsets);
709            }
710        } else if let Some(node) = schema_mapping_get(mapping, "outline") {
711            // `outline` and `sections` share the nested-rule key space: an
712            // outline rule's children live in the scope its index names. The
713            // two lists are mutually exclusive, so when both appear the load
714            // is already failing and only the `sections` forest — the one the
715            // legacy validation errors point into — keeps its ranges.
716            let expansion = subtree_expansion(node, expansion);
717            if let Some(entries) = node.as_sequence() {
718                index.collect_outline(entries, expansion, char_offsets);
719            }
720        }
721        if let Some(node) = schema_mapping_get(mapping, "constraints") {
722            let expansion = subtree_expansion(node, expansion);
723            if let Some(constraints) = node.as_sequence() {
724                index.collect_constraints(
725                    constraints,
726                    &ScopePath(Vec::new()),
727                    expansion,
728                    char_offsets,
729                );
730            }
731        }
732        index
733    }
734
735    fn collect_rules(
736        &mut self,
737        rules: &[SchemaYamlNode],
738        scope: &ScopePath,
739        expansion: Option<(usize, usize)>,
740        char_offsets: &[usize],
741    ) {
742        for (index, node) in rules.iter().enumerate() {
743            let path = RulePath {
744                scope: scope.clone(),
745                index: RuleIndex(index),
746            };
747            self.ranges.insert(
748                RangeKey::Rule(path.clone()),
749                node_range(node, expansion, char_offsets),
750            );
751            let expansion = subtree_expansion(node, expansion);
752            let Some(mapping) = node.as_mapping() else {
753                continue;
754            };
755            for &field in RULE_FIELDS {
756                if let Some(value) = schema_mapping_get(mapping, field) {
757                    self.ranges.insert(
758                        RangeKey::RuleField(path.clone(), field.into()),
759                        node_range(value, expansion, char_offsets),
760                    );
761                }
762            }
763            let mut child_scope = scope.clone();
764            child_scope.0.push(RuleIndex(index));
765            if let Some(node) = schema_mapping_get(mapping, "sections") {
766                let expansion = subtree_expansion(node, expansion);
767                if let Some(children) = node.as_sequence() {
768                    self.collect_rules(children, &child_scope, expansion, char_offsets);
769                }
770            }
771            if let Some(node) = schema_mapping_get(mapping, "constraints") {
772                let expansion = subtree_expansion(node, expansion);
773                if let Some(constraints) = node.as_sequence() {
774                    self.collect_constraints(constraints, &child_scope, expansion, char_offsets);
775                }
776            }
777        }
778    }
779
780    fn collect_outline(
781        &mut self,
782        entries: &[SchemaYamlNode],
783        expansion: Option<(usize, usize)>,
784        char_offsets: &[usize],
785    ) {
786        for (index, node) in entries.iter().enumerate() {
787            self.ranges.insert(
788                RangeKey::OutlineRule(RuleIndex(index)),
789                node_range(node, expansion, char_offsets),
790            );
791            let expansion = subtree_expansion(node, expansion);
792            let Some(mapping) = node.as_mapping() else {
793                continue;
794            };
795            for &field in RULE_FIELDS {
796                if let Some(value) = schema_mapping_get(mapping, field) {
797                    self.ranges.insert(
798                        RangeKey::OutlineRuleField(RuleIndex(index), field.into()),
799                        node_range(value, expansion, char_offsets),
800                    );
801                }
802            }
803            let child_scope = ScopePath(vec![RuleIndex(index)]);
804            if let Some(node) = schema_mapping_get(mapping, "sections") {
805                let expansion = subtree_expansion(node, expansion);
806                if let Some(children) = node.as_sequence() {
807                    self.collect_rules(children, &child_scope, expansion, char_offsets);
808                }
809            }
810            if let Some(node) = schema_mapping_get(mapping, "constraints") {
811                let expansion = subtree_expansion(node, expansion);
812                if let Some(constraints) = node.as_sequence() {
813                    self.collect_constraints(constraints, &child_scope, expansion, char_offsets);
814                }
815            }
816        }
817    }
818
819    fn collect_constraints(
820        &mut self,
821        constraints: &[SchemaYamlNode],
822        scope: &ScopePath,
823        expansion: Option<(usize, usize)>,
824        char_offsets: &[usize],
825    ) {
826        for (index, node) in constraints.iter().enumerate() {
827            self.ranges.insert(
828                RangeKey::Constraint(ConstraintPath {
829                    scope: scope.clone(),
830                    index: ConstraintIndex(index),
831                }),
832                node_range(node, expansion, char_offsets),
833            );
834        }
835    }
836
837    fn get(&self, key: &RangeKey, fallback: SourceRange) -> SourceRange {
838        self.ranges.get(key).copied().unwrap_or(fallback)
839    }
840}
841
842/// One node of the tree the schema loader builds out of parser events.
843///
844/// A mapping keeps its entries as an ordered `Vec` rather than a map so that
845/// two keys spelled differently but resolving alike stay visible to the
846/// duplicate checks, and so that a key which is not a scalar at all still has
847/// somewhere to live until the conversion rejects it. Collection tags are
848/// validated as the events arrive — a schema document may carry no
849/// non-standard tag at all — so only scalars still hold theirs, for the
850/// conversion that resolves their values.
851#[derive(Clone, Debug)]
852struct SchemaYamlNode {
853    kind: SchemaYamlKind,
854    /// Half-open character-index range of the node's own spelling. A scalar's
855    /// end is the parser's own, which is real under this engine; a
856    /// collection's start event is zero-width but its marker sits on the
857    /// collection's first token, so the range pairs it with the end event's
858    /// far edge.
859    start: usize,
860    end: usize,
861    /// The node is an alias's copy, and its range is the alias site. The whole
862    /// copy anchors there: the ranges its entries carry belong to the anchor's
863    /// definition, which is not the entry a range key into the copy names, and
864    /// §6.2 permits the nearest enclosing entry with a position of its own —
865    /// which the alias site is.
866    expanded: bool,
867}
868
869#[derive(Clone, Debug, PartialEq, Eq, Hash)]
870enum SchemaYamlKind {
871    Scalar(ExactYamlScalar),
872    Sequence(Vec<SchemaYamlNode>),
873    Mapping(Vec<(SchemaYamlNode, SchemaYamlNode)>),
874}
875
876/// Equality ignores positions: the duplicate checks ask whether two keys are
877/// the same key, and two spellings of one key are no less duplicates for
878/// sitting on different lines.
879impl PartialEq for SchemaYamlNode {
880    fn eq(&self, other: &Self) -> bool {
881        self.kind == other.kind
882    }
883}
884
885impl Eq for SchemaYamlNode {}
886
887impl std::hash::Hash for SchemaYamlNode {
888    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
889        self.kind.hash(state);
890    }
891}
892
893impl SchemaYamlNode {
894    fn as_mapping(&self) -> Option<&[(SchemaYamlNode, SchemaYamlNode)]> {
895        match &self.kind {
896            SchemaYamlKind::Mapping(entries) => Some(entries),
897            _ => None,
898        }
899    }
900
901    fn as_sequence(&self) -> Option<&[SchemaYamlNode]> {
902        match &self.kind {
903            SchemaYamlKind::Sequence(values) => Some(values),
904            _ => None,
905        }
906    }
907
908    fn scalar_text(&self) -> Option<&str> {
909        match &self.kind {
910            SchemaYamlKind::Scalar(scalar) => Some(&scalar.value),
911            _ => None,
912        }
913    }
914}
915
916/// The value of the entry whose key spells `key`, by linear scan.
917///
918/// Scalar keys compare on their text, so `version` and `"version"` name the
919/// same field here exactly as they collide in the JSON object the document
920/// converts to.
921fn schema_mapping_get<'a>(
922    entries: &'a [(SchemaYamlNode, SchemaYamlNode)],
923    key: &str,
924) -> Option<&'a SchemaYamlNode> {
925    entries
926        .iter()
927        .find(|(candidate, _)| candidate.scalar_text() == Some(key))
928        .map(|(_, value)| value)
929}
930
931/// The range a subtree's entries anchor at, once an alias expansion encloses
932/// them: the alias site's own range, carried down from the copy's root.
933fn subtree_expansion(
934    node: &SchemaYamlNode,
935    inherited: Option<(usize, usize)>,
936) -> Option<(usize, usize)> {
937    inherited.or_else(|| node.expanded.then_some((node.start, node.end)))
938}
939
940/// Converts one node's character-index range into a byte-offset source range.
941fn node_range(
942    node: &SchemaYamlNode,
943    expansion: Option<(usize, usize)>,
944    char_offsets: &[usize],
945) -> SourceRange {
946    let (start, end) = expansion.unwrap_or((node.start, node.end));
947    char_range(start, end, char_offsets)
948}
949
950/// A half-open character-index range as a byte-offset range into the source.
951///
952/// `saphyr-parser` markers count characters, while Outlint source ranges are
953/// UTF-8 byte offsets; the caller's table bridges the units, so no marker
954/// index ever slices the source directly. A zero-width range — a parse
955/// error's marker, or a scalar the parser synthesised for an entry with no
956/// spelling of its own — is widened to the one character it points at, so a
957/// caret has something to sit under; at the end of input it stays empty.
958fn char_range(start: usize, end: usize, char_offsets: &[usize]) -> SourceRange {
959    let source_end = char_offsets.last().copied().unwrap_or(0);
960    let start_byte = char_offsets.get(start).copied().unwrap_or(source_end);
961    let mut end_byte = char_offsets
962        .get(end)
963        .copied()
964        .unwrap_or(source_end)
965        .max(start_byte);
966    if end_byte <= start_byte {
967        end_byte = char_offsets
968            .get(start + 1)
969            .copied()
970            .unwrap_or(end_byte)
971            .max(end_byte);
972    }
973    SourceRange {
974        source: SourceId(0),
975        range: TextRange {
976            start: ByteOffset(start_byte),
977            end: ByteOffset(end_byte),
978        },
979    }
980}
981
982/// A schema document the YAML engine refused, before validation began.
983///
984/// The range is in character indices — `None` anchors at the whole document —
985/// and the kind rides along because not every refusal is a syntax error: a
986/// non-string mapping key, for example, is a shape complaint with a position.
987#[derive(Debug)]
988struct SchemaYamlError {
989    kind: SchemaErrorKind,
990    span: Option<(usize, usize)>,
991    message: String,
992}
993
994impl SchemaYamlError {
995    fn syntax(span: &Span, mark: usize, message: String) -> Self {
996        Self {
997            kind: SchemaErrorKind::Syntax,
998            span: Some((
999                span.start.index() + mark,
1000                (span.end.index() + mark).max(span.start.index() + mark),
1001            )),
1002            message,
1003        }
1004    }
1005}
1006
1007/// A parsed node held for the aliases that name it, with its size and depth.
1008///
1009/// Both numbers exist so an alias can be charged before the copy is made: the
1010/// size against the node budget, and the depth against the nesting limit the
1011/// copy carries to wherever it lands.
1012#[derive(Debug)]
1013struct AnchoredSchemaYamlNode {
1014    node: SchemaYamlNode,
1015    nodes: usize,
1016    depth: usize,
1017}
1018
1019/// A node just built, beside how deeply its own collections nest — carried out
1020/// of the build because measuring it afterwards would be another walk of the
1021/// same recursion the depth bound exists to keep within the stack.
1022#[derive(Debug)]
1023struct SchemaYamlSubtree {
1024    node: SchemaYamlNode,
1025    depth: usize,
1026}
1027
1028/// Builds the schema tree by pulling one event at a time from `saphyr-parser`.
1029///
1030/// This is the schema-document counterpart of the frontmatter reader in
1031/// `markdown.rs`, and it carries the same three protections through the same
1032/// shared machinery: the [`ExactYamlBudget`] that bounds alias expansion by
1033/// the input's own size, the [`MAX_YAML_DEPTH`](crate::markdown::MAX_YAML_DEPTH)
1034/// bound charged as the recursion descends, and the
1035/// alias-charged-before-clone ordering that refuses a bomb before building
1036/// it. What differs is only what a node remembers — character spans for
1037/// [`RangeIndex`], where frontmatter keeps line and column — and the words a
1038/// refusal is reported in.
1039struct SchemaYamlReader<'source> {
1040    parser: YamlParser<'source, StrInput<'source>>,
1041    anchors: BTreeMap<usize, AnchoredSchemaYamlNode>,
1042    budget: ExactYamlBudget,
1043    /// Characters removed from the head of the source before parsing — a
1044    /// byte-order mark or nothing — counted back into every reported index.
1045    mark: usize,
1046}
1047
1048impl<'source> SchemaYamlReader<'source> {
1049    fn new(source: &'source str, mark: usize) -> Self {
1050        Self {
1051            parser: YamlParser::new_from_str(source),
1052            anchors: BTreeMap::new(),
1053            budget: ExactYamlBudget::default(),
1054            mark,
1055        }
1056    }
1057
1058    /// Reads the next event, charging the budget for the input it took.
1059    fn next_event(&mut self) -> Result<(YamlEvent<'source>, Span), SchemaYamlError> {
1060        self.budget.events += 1;
1061        match self.parser.next_event() {
1062            Some(Ok(read)) => Ok(read),
1063            Some(Err(error)) => {
1064                let marker = error.marker();
1065                let span = Span::new(*marker, *marker);
1066                // `ScanError`'s own rendering calls its character index a byte
1067                // and holds a zero-based column, so the position is respelled:
1068                // a one-based line and a one-based character column, with a
1069                // removed byte-order mark counted back into the first line.
1070                let column = marker.col() + 1 + if marker.line() == 1 { self.mark } else { 0 };
1071                Err(SchemaYamlError::syntax(
1072                    &span,
1073                    self.mark,
1074                    format!(
1075                        "invalid YAML: {} at line {} column {column}",
1076                        error.info(),
1077                        marker.line(),
1078                    ),
1079                ))
1080            }
1081            None => Err(SchemaYamlError {
1082                kind: SchemaErrorKind::Syntax,
1083                span: None,
1084                message: "invalid YAML: the document ends before its structure does".into(),
1085            }),
1086        }
1087    }
1088
1089    /// Refuses the second document a schema must not contain, at its start.
1090    ///
1091    /// The refusal lands before any of the second document's content is read:
1092    /// raw `next_event` does not clear the parser's anchor table between
1093    /// documents, so reading on would resolve the second document's aliases
1094    /// against the first one's anchors. The serde-era engine reported this
1095    /// verdict with no location at all; the start event's span is a real one.
1096    fn second_document_error(&self, span: &Span) -> SchemaYamlError {
1097        let column = span.start.col() + 1 + if span.start.line() == 1 { self.mark } else { 0 };
1098        SchemaYamlError::syntax(
1099            span,
1100            self.mark,
1101            format!(
1102                "invalid YAML: a second document opens at line {} column {column}; \
1103                 a schema is a single YAML document",
1104                span.start.line(),
1105            ),
1106        )
1107    }
1108
1109    /// Rejects every tag outside the `tag:yaml.org,2002:` namespace.
1110    ///
1111    /// The core-schema tags keep the meaning the conversion gives them; a
1112    /// non-standard tag has no meaning a schema document could put to use, and
1113    /// the engine this loader left rejected such documents too.
1114    fn reject_non_standard_tag(
1115        &self,
1116        tag: Option<&YamlTag>,
1117        span: &Span,
1118    ) -> Result<(), SchemaYamlError> {
1119        match tag {
1120            Some(tag) if !tag.is_yaml_core_schema() => Err(SchemaYamlError::syntax(
1121                span,
1122                self.mark,
1123                format!(
1124                    "invalid YAML: non-standard tag `{}{}`",
1125                    tag.handle, tag.suffix
1126                ),
1127            )),
1128            _ => Ok(()),
1129        }
1130    }
1131
1132    fn depth_error(&self, span: &Span) -> SchemaYamlError {
1133        SchemaYamlError::syntax(
1134            span,
1135            self.mark,
1136            "invalid YAML: nesting exceeds the depth limit".into(),
1137        )
1138    }
1139
1140    fn budget_error(&self, span: &Span) -> SchemaYamlError {
1141        SchemaYamlError::syntax(
1142            span,
1143            self.mark,
1144            "invalid YAML: alias expansion exceeds the document's size limit".into(),
1145        )
1146    }
1147
1148    fn value_error(&self, error: YamlValueError, span: &Span) -> SchemaYamlError {
1149        SchemaYamlError::syntax(span, self.mark, schema_value_error(error))
1150    }
1151
1152    /// Builds the node the given event opens, reading whatever it contains.
1153    ///
1154    /// `depth` counts the collections already open around this node; the
1155    /// document's own root mapping is the first level, and the bound is
1156    /// charged before the frame is taken rather than after. What the node
1157    /// reaches below itself is returned with it, since an alias to it has to
1158    /// be charged that depth at a site this call knows nothing of.
1159    fn node(
1160        &mut self,
1161        event: YamlEvent<'source>,
1162        span: Span,
1163        depth: usize,
1164    ) -> Result<SchemaYamlSubtree, SchemaYamlError> {
1165        let spent = self.budget.nodes;
1166        let start = span.start.index() + self.mark;
1167        let (kind, end, anchor, reached) = match event {
1168            YamlEvent::Scalar(value, style, anchor, tag) => {
1169                let tag = tag.map(Cow::into_owned);
1170                self.reject_non_standard_tag(tag.as_ref(), &span)?;
1171                self.budget.spend(1).map_err(|_| self.budget_error(&span))?;
1172                (
1173                    SchemaYamlKind::Scalar(ExactYamlScalar {
1174                        value: value.into_owned(),
1175                        style,
1176                        tag,
1177                    }),
1178                    span.end.index() + self.mark,
1179                    anchor,
1180                    0,
1181                )
1182            }
1183            YamlEvent::SequenceStart(anchor, tag) => {
1184                let tag = tag.map(Cow::into_owned);
1185                self.reject_non_standard_tag(tag.as_ref(), &span)?;
1186                validate_yaml_container_tag(tag.as_ref(), "seq")
1187                    .map_err(|error| self.value_error(error, &span))?;
1188                let depth = deeper_yaml_nesting(depth, 1).map_err(|_| self.depth_error(&span))?;
1189                self.budget.spend(1).map_err(|_| self.budget_error(&span))?;
1190                let mut values = Vec::new();
1191                let mut inner = 0;
1192                let end;
1193                loop {
1194                    let (event, span) = self.next_event()?;
1195                    if matches!(event, YamlEvent::SequenceEnd) {
1196                        end = span.end.index() + self.mark;
1197                        break;
1198                    }
1199                    let value = self.node(event, span, depth)?;
1200                    inner = inner.max(value.depth);
1201                    values.push(value.node);
1202                }
1203                (SchemaYamlKind::Sequence(values), end, anchor, inner + 1)
1204            }
1205            YamlEvent::MappingStart(anchor, tag) => {
1206                let tag = tag.map(Cow::into_owned);
1207                self.reject_non_standard_tag(tag.as_ref(), &span)?;
1208                validate_yaml_container_tag(tag.as_ref(), "map")
1209                    .map_err(|error| self.value_error(error, &span))?;
1210                let depth = deeper_yaml_nesting(depth, 1).map_err(|_| self.depth_error(&span))?;
1211                self.budget.spend(1).map_err(|_| self.budget_error(&span))?;
1212                let mut entries: Vec<(SchemaYamlNode, SchemaYamlNode)> = Vec::new();
1213                let mut keys: BTreeMap<u64, Vec<usize>> = BTreeMap::new();
1214                let mut inner = 0;
1215                let end;
1216                loop {
1217                    let (event, span) = self.next_event()?;
1218                    if matches!(event, YamlEvent::MappingEnd) {
1219                        end = span.end.index() + self.mark;
1220                        break;
1221                    }
1222                    let key = self.node(event, span, depth)?;
1223                    let (event, span) = self.next_event()?;
1224                    let value = self.node(event, span, depth)?;
1225                    inner = inner.max(key.depth).max(value.depth);
1226                    let (key, value) = (key.node, value.node);
1227                    // Whole-node equality catches the keys the conversion
1228                    // never reduces to a string; keys that do resolve are
1229                    // caught again there, on the resolved text. The digest
1230                    // narrows the candidates so an aliased flood of large
1231                    // keys costs hashes rather than quadratic comparisons.
1232                    let digest = schema_yaml_key_digest(&key);
1233                    let alike = keys.entry(digest).or_default();
1234                    if alike.iter().any(|&entry| entries[entry].0 == key) {
1235                        return Err(duplicate_schema_key_error(&key));
1236                    }
1237                    alike.push(entries.len());
1238                    entries.push((key, value));
1239                }
1240                (SchemaYamlKind::Mapping(entries), end, anchor, inner + 1)
1241            }
1242            YamlEvent::Alias(anchor) => {
1243                let Some(anchored) = self.anchors.get(&anchor) else {
1244                    return Err(SchemaYamlError::syntax(
1245                        &span,
1246                        self.mark,
1247                        "invalid YAML: unresolved alias".into(),
1248                    ));
1249                };
1250                // Charged before the clone, size and depth both: a tree too
1251                // large or too deep to walk must not be built in order to
1252                // discover that it is.
1253                let (nodes, reached) = (anchored.nodes, anchored.depth);
1254                deeper_yaml_nesting(depth, reached).map_err(|_| self.depth_error(&span))?;
1255                self.budget
1256                    .spend(nodes)
1257                    .map_err(|_| self.budget_error(&span))?;
1258                let mut node = self
1259                    .anchors
1260                    .get(&anchor)
1261                    .expect("charged against a node the table holds")
1262                    .node
1263                    .clone();
1264                // The whole copy anchors at the alias site: its root takes the
1265                // site's own span, and `expanded` tells every walk to carry
1266                // that range over the definition spans the copy's entries
1267                // still hold. See [`SchemaYamlNode::expanded`].
1268                node.start = start;
1269                node.end = (span.end.index() + self.mark).max(start);
1270                node.expanded = true;
1271                return Ok(SchemaYamlSubtree {
1272                    node,
1273                    depth: reached,
1274                });
1275            }
1276            _ => {
1277                return Err(SchemaYamlError {
1278                    kind: SchemaErrorKind::Syntax,
1279                    span: None,
1280                    message: "invalid YAML: unexpected document boundary".into(),
1281                })
1282            }
1283        };
1284        let node = SchemaYamlNode {
1285            kind,
1286            start,
1287            end: end.max(start),
1288            expanded: false,
1289        };
1290        if anchor != 0 {
1291            // Anchor zero is `saphyr-parser`'s "no anchor", and a node is
1292            // registered only once it is built, so a collection cannot alias
1293            // itself: the alias inside is refused as unresolved.
1294            self.anchors.insert(
1295                anchor,
1296                AnchoredSchemaYamlNode {
1297                    node: node.clone(),
1298                    nodes: self.budget.nodes - spent,
1299                    depth: reached,
1300                },
1301            );
1302        }
1303        Ok(SchemaYamlSubtree {
1304            node,
1305            depth: reached,
1306        })
1307    }
1308}
1309
1310/// Digests a mapping key so only the keys that could equal it are compared.
1311fn schema_yaml_key_digest(key: &SchemaYamlNode) -> u64 {
1312    let mut hasher = std::hash::DefaultHasher::new();
1313    std::hash::Hash::hash(key, &mut hasher);
1314    std::hash::Hasher::finish(&hasher)
1315}
1316
1317/// Names a duplicate mapping key at the duplicate occurrence's own range.
1318fn duplicate_schema_key_error(key: &SchemaYamlNode) -> SchemaYamlError {
1319    SchemaYamlError {
1320        kind: SchemaErrorKind::Syntax,
1321        span: Some((key.start, key.end)),
1322        message: match key.scalar_text() {
1323            Some(text) => format!("invalid YAML: duplicate mapping key `{text}`"),
1324            None => "invalid YAML: duplicate mapping key".into(),
1325        },
1326    }
1327}
1328
1329/// Reads a schema document's one YAML document, keeping every span.
1330///
1331/// A leading byte-order mark is removed before parsing — the parser would
1332/// otherwise deliver it as the first character of the first key, leaving a
1333/// document whose `version` entry is invisibly named something else — and
1334/// every reported index counts it back in. A source holding no document at
1335/// all parses as an empty scalar, which the shape validation then rejects as
1336/// the non-mapping it is. A second document is refused at its own start
1337/// marker; see [`SchemaYamlReader::second_document_error`].
1338fn parse_schema_yaml(source: &str) -> Result<SchemaYamlNode, SchemaYamlError> {
1339    let (body, mark) = match source.strip_prefix('\u{feff}') {
1340        Some(body) => (body, 1),
1341        None => (source, 0),
1342    };
1343    let mut reader = SchemaYamlReader::new(body, mark);
1344    let boundary_error = || SchemaYamlError {
1345        kind: SchemaErrorKind::Syntax,
1346        span: None,
1347        message: "invalid YAML: unexpected document boundary".into(),
1348    };
1349    let (event, _) = reader.next_event()?;
1350    if !matches!(event, YamlEvent::StreamStart) {
1351        return Err(boundary_error());
1352    }
1353    let (event, _) = reader.next_event()?;
1354    if matches!(event, YamlEvent::StreamEnd) {
1355        // Nothing but comments or blank lines: the empty scalar the YAML data
1356        // model gives such a stream, which fails shape validation as a null.
1357        return Ok(SchemaYamlNode {
1358            kind: SchemaYamlKind::Scalar(ExactYamlScalar {
1359                value: "~".into(),
1360                style: ScalarStyle::Plain,
1361                tag: None,
1362            }),
1363            start: 0,
1364            end: 0,
1365            expanded: false,
1366        });
1367    }
1368    if !matches!(event, YamlEvent::DocumentStart(_)) {
1369        return Err(boundary_error());
1370    }
1371    let (event, span) = reader.next_event()?;
1372    let value = reader.node(event, span, 0)?.node;
1373    let (event, _) = reader.next_event()?;
1374    if !matches!(event, YamlEvent::DocumentEnd) {
1375        return Err(boundary_error());
1376    }
1377    match reader.next_event()? {
1378        (YamlEvent::StreamEnd, _) => Ok(value),
1379        (YamlEvent::DocumentStart(_), span) => Err(reader.second_document_error(&span)),
1380        _ => Err(boundary_error()),
1381    }
1382}
1383
1384/// Converts the parsed tree into the JSON value domain validation runs in.
1385///
1386/// Scalars resolve through the same conversion the frontmatter path uses, so
1387/// a scalar means the same thing in both document kinds, §1.6-exactness
1388/// included. Mapping keys must resolve to strings here — the JSON object this
1389/// builds has no other kind of key — and the resolved text is where two
1390/// spellings of one key are recognised as the duplicate they are.
1391fn schema_yaml_to_json(node: SchemaYamlNode) -> Result<Value, SchemaYamlError> {
1392    let span = (node.start, node.end);
1393    match node.kind {
1394        SchemaYamlKind::Scalar(scalar) => {
1395            exact_yaml_scalar_to_json(scalar).map_err(|error| SchemaYamlError {
1396                kind: SchemaErrorKind::Syntax,
1397                span: Some(span),
1398                message: schema_value_error(error),
1399            })
1400        }
1401        SchemaYamlKind::Sequence(values) => Ok(Value::Array(
1402            values
1403                .into_iter()
1404                .map(schema_yaml_to_json)
1405                .collect::<Result<_, _>>()?,
1406        )),
1407        SchemaYamlKind::Mapping(entries) => {
1408            let mut object = JsonMap::new();
1409            for (key, value) in entries {
1410                let key_span = (key.start, key.end);
1411                let non_string_key = || SchemaYamlError {
1412                    kind: SchemaErrorKind::InvalidDocumentShape,
1413                    span: Some(key_span),
1414                    message: "mapping keys must be strings".into(),
1415                };
1416                let SchemaYamlKind::Scalar(scalar) = key.kind else {
1417                    return Err(non_string_key());
1418                };
1419                let Value::String(key) =
1420                    exact_yaml_scalar_to_json(scalar).map_err(|error| SchemaYamlError {
1421                        kind: SchemaErrorKind::Syntax,
1422                        span: Some(key_span),
1423                        message: schema_value_error(error),
1424                    })?
1425                else {
1426                    return Err(non_string_key());
1427                };
1428                let value = schema_yaml_to_json(value)?;
1429                if object.contains_key(&key) {
1430                    return Err(SchemaYamlError {
1431                        kind: SchemaErrorKind::Syntax,
1432                        span: Some(key_span),
1433                        message: format!("invalid YAML: duplicate mapping key `{key}`"),
1434                    });
1435                }
1436                object.insert(key, value);
1437            }
1438            Ok(Value::Object(object))
1439        }
1440    }
1441}
1442
1443/// The schema-document wording for a scalar or tag with no JSON value.
1444fn schema_value_error(error: YamlValueError) -> String {
1445    match error {
1446        YamlValueError::TaggedNull => "invalid YAML: invalid explicitly tagged null".into(),
1447        YamlValueError::TaggedBool => "invalid YAML: invalid explicitly tagged boolean".into(),
1448        YamlValueError::TaggedInt => "invalid YAML: invalid explicitly tagged integer".into(),
1449        YamlValueError::TaggedFloat => "invalid YAML: invalid explicitly tagged float".into(),
1450        YamlValueError::ScalarTag => "invalid YAML: invalid tag for a YAML scalar".into(),
1451        YamlValueError::ContainerTag(expected) => {
1452            format!("invalid YAML: invalid tag for a YAML {expected}")
1453        }
1454        YamlValueError::NonFinite => "invalid YAML: a non-finite number has no JSON value".into(),
1455        YamlValueError::Unrepresentable { lexeme, error } => {
1456            format!("invalid YAML: number `{lexeme}` is not representable: {error}")
1457        }
1458    }
1459}
1460
1461struct Loader {
1462    sources: SchemaSources,
1463    document_range: SourceRange,
1464    /// Character-index → byte-offset bridge for the primary source, shared by
1465    /// the range index and every positioned refusal the parse produced.
1466    char_offsets: Vec<usize>,
1467    /// The one tree parsed over the source, or the refusal that stopped it.
1468    /// Consumed by [`Loader::load`]; the range index was read off it first.
1469    parsed: Option<Result<SchemaYamlNode, SchemaYamlError>>,
1470    ranges: RangeIndex,
1471    errors: Vec<SchemaError>,
1472    nodes: BTreeMap<SchemaNode, SourceRange>,
1473    raw_constraints: BTreeMap<ScopePath, Vec<Value>>,
1474    external_schema: Option<PreparedExternalSchema>,
1475    /// Whether the document declares the general `outline:` form.
1476    ///
1477    /// The outline's rules are built at the empty scope they semantically
1478    /// live in, while their spellings were collected under the dedicated
1479    /// outline range keys; this flag makes [`Loader::source_key`] bridge the
1480    /// two. Sugar schemas need no bridge: their `sections` forest is both
1481    /// spelled and built at the empty scope.
1482    outline_general: bool,
1483}
1484
1485impl Loader {
1486    fn new(
1487        source: Arc<str>,
1488        label: Option<SourceLabel>,
1489        external_schema: Option<LinkedJsonSchemaInput>,
1490    ) -> Self {
1491        let document_range = SourceRange {
1492            source: SourceId(0),
1493            range: TextRange {
1494                start: ByteOffset(0),
1495                end: ByteOffset(source.len()),
1496            },
1497        };
1498        let char_offsets = source
1499            .char_indices()
1500            .map(|(offset, _)| offset)
1501            .chain(std::iter::once(source.len()))
1502            .collect::<Vec<_>>();
1503        // One parse serves everything: the reader bounds nesting and alias
1504        // expansion itself, so the tree it yields is safe for every recursive
1505        // walk that follows — the range index here, the conversion in `load`.
1506        let parsed = parse_schema_yaml(&source);
1507        let ranges = match &parsed {
1508            Ok(tree) => RangeIndex::from_tree(tree, &char_offsets),
1509            Err(_) => RangeIndex::default(),
1510        };
1511        let mut sources = primary_sources(Arc::clone(&source), label);
1512        let external_schema = external_schema.map(|external| {
1513            let mut source_ids = BTreeMap::new();
1514            let mut source_id_exhausted = false;
1515            for (index, resource) in external.resources.iter().enumerate() {
1516                let Some(id) = external_source_id(index) else {
1517                    source_id_exhausted = true;
1518                    break;
1519                };
1520                source_ids.entry(resource.uri.clone()).or_insert(id);
1521                sources.documents.insert(
1522                    id,
1523                    SchemaSource {
1524                        label: resource.label.clone(),
1525                        text: match &resource.contents {
1526                            JsonSchemaResourceContents::Loaded(text) => Arc::clone(text),
1527                            JsonSchemaResourceContents::ReadFailure(_) => Arc::from(""),
1528                        },
1529                    },
1530                );
1531            }
1532            if source_id_exhausted {
1533                PreparedExternalSchema {
1534                    root_source: SourceId(0),
1535                    result: Err(single_external_error(PreparedExternalError {
1536                        source: SourceId(0),
1537                        message: "too many linked JSON Schema resources to assign source ids"
1538                            .into(),
1539                    })),
1540                }
1541            } else {
1542                prepare_external_schema(&external, &source_ids)
1543            }
1544        });
1545        Self {
1546            sources,
1547            document_range,
1548            char_offsets,
1549            parsed: Some(parsed),
1550            ranges,
1551            errors: Vec::new(),
1552            nodes: BTreeMap::new(),
1553            raw_constraints: BTreeMap::new(),
1554            external_schema,
1555            outline_general: false,
1556        }
1557    }
1558
1559    fn load(mut self) -> LoadSchemaResult {
1560        let parsed = self
1561            .parsed
1562            .take()
1563            .expect("the tree is parsed once and consumed once");
1564        let tree = match parsed {
1565            Ok(tree) => tree,
1566            Err(error) => {
1567                self.push_yaml_error(error);
1568                return self.failure();
1569            }
1570        };
1571        let value = match schema_yaml_to_json(tree) {
1572            Ok(value) => value,
1573            Err(error) => {
1574                self.push_yaml_error(error);
1575                return self.failure();
1576            }
1577        };
1578
1579        self.validate_document_shape(&value);
1580        if !self.errors.is_empty() {
1581            return self.failure();
1582        }
1583
1584        // The shapes are validated against the tree's ranges first because
1585        // serde's data-model errors carry no positions at all.
1586        let frontmatter_declared = value
1587            .as_object()
1588            .is_some_and(|mapping| mapping.contains_key("frontmatter"));
1589        // Serde folds `title: null` and an absent `title` into the same
1590        // `None`, but they declare different things: null is an explicit "no
1591        // h1", absence is the bare-sections sugar.
1592        let title_null = value
1593            .as_object()
1594            .is_some_and(|mapping| matches!(mapping.get("title"), Some(Value::Null)));
1595        let raw: RawSchema = match serde_json::from_value(value) {
1596            Ok(raw) => raw,
1597            Err(error) => {
1598                self.error_at(
1599                    SchemaErrorKind::InvalidDocumentShape,
1600                    self.document_range,
1601                    format!("invalid schema document shape: {error}"),
1602                );
1603                return self.failure();
1604            }
1605        };
1606
1607        let version_range = self.range(RangeKey::DocumentField("version".into()));
1608        let version = if raw.version == 1 {
1609            Some(SchemaVersion::V1)
1610        } else {
1611            self.error_at(
1612                SchemaErrorKind::UnsupportedVersion,
1613                version_range,
1614                format!("unsupported schema version {}; expected 1", raw.version),
1615            );
1616            None
1617        };
1618
1619        let frontmatter = self.build_frontmatter(raw.frontmatter, frontmatter_declared);
1620
1621        let options = Self::build_options(&raw.options);
1622        let match_case = options.match_case;
1623        let ordered_default = options.ordered_sections;
1624        let root_scope = ScopePath(Vec::new());
1625        // The empty scope key names what the source's top level spelled: the
1626        // outline scope for the general form, the `sections` scope for sugar.
1627        // `constraints_mut` routes it to the matching place in the built
1628        // schema, so both forms share the collection here.
1629        self.raw_constraints
1630            .insert(root_scope.clone(), raw.constraints);
1631        let (outline, outline_provenance) = if let Some(entries) = raw.outline {
1632            self.outline_general = true;
1633            (
1634                self.build_outline_scope(entries, &root_scope, match_case, ordered_default),
1635                OutlineProvenance::Outline,
1636            )
1637        } else {
1638            let outline_provenance = if title_null {
1639                OutlineProvenance::NoTitle
1640            } else if raw.title.is_some() {
1641                OutlineProvenance::Title
1642            } else {
1643                OutlineProvenance::BareSections
1644            };
1645            let title = raw.title.as_deref().and_then(|matcher| {
1646                let range = self.range(RangeKey::DocumentField("title".into()));
1647                self.nodes.insert(SchemaNode::Title, range);
1648                self.build_matcher(matcher, match_case, range)
1649            });
1650            if title_null {
1651                let range = self.range(RangeKey::DocumentField("title".into()));
1652                self.nodes.insert(SchemaNode::Title, range);
1653            }
1654            if outline_provenance == OutlineProvenance::BareSections {
1655                // Bare `sections:` implies `title: "*"`, but there is no
1656                // `title:` key to anchor title diagnostics on. The `sections`
1657                // key is the spelling that implied the rule, so it carries
1658                // the anchor.
1659                let range = self.range(RangeKey::DocumentField("sections".into()));
1660                self.nodes.insert(SchemaNode::Title, range);
1661            }
1662            let sections = self.build_scope(
1663                raw.sections
1664                    .expect("the shape validation requires `sections` without `outline`"),
1665                &root_scope,
1666                match_case,
1667                ordered_default,
1668            );
1669            // The sugar desugars UP into the canonical h1-rule list: one
1670            // synthesized rule whose matcher is the declared title (any text
1671            // when none is declared), required exactly once — or denied for
1672            // `title: null` — with the `sections` list as its child scope.
1673            // The rule has no id and no spelling of its own: publicly it is
1674            // `SchemaNode::Title`, and public scopes address its children.
1675            let outline = sections.map(|sections| {
1676                vec![SectionRule {
1677                    id: None,
1678                    // A failed title matcher already pushed its error; the
1679                    // any-text placeholder never reaches a caller because the
1680                    // load fails below.
1681                    matcher: title.unwrap_or(Matcher::Any),
1682                    outcome: if title_null {
1683                        RuleOutcome::Deny
1684                    } else {
1685                        RuleOutcome::Allow(Cardinality {
1686                            min: 1,
1687                            max: UpperBound::Bounded(1),
1688                        })
1689                    },
1690                    strict: false,
1691                    // The sugar has no rule to carry `ordered`, so its
1692                    // `sections` scope follows the option, like the general
1693                    // form's outline scope.
1694                    ordered: ordered_default,
1695                    sections,
1696                    constraints: Vec::new(),
1697                }]
1698            });
1699            (outline, outline_provenance)
1700        };
1701
1702        let (Some(version), Some(frontmatter), Some(outline)) = (version, frontmatter, outline)
1703        else {
1704            self.validate_constraint_lexical_refs();
1705            return self.failure();
1706        };
1707        let mut schema = Schema {
1708            version,
1709            options,
1710            frontmatter,
1711            outline,
1712            constraints: Vec::new(),
1713            outline_provenance,
1714        };
1715
1716        let mut normalized = BTreeMap::new();
1717        for (scope, constraints) in std::mem::take(&mut self.raw_constraints) {
1718            let mut built = Vec::with_capacity(constraints.len());
1719            for (index, constraint) in constraints.into_iter().enumerate() {
1720                let range = self.range(RangeKey::Constraint(ConstraintPath {
1721                    scope: scope.clone(),
1722                    index: ConstraintIndex(index),
1723                }));
1724                self.nodes.insert(
1725                    SchemaNode::Constraint(ConstraintPath {
1726                        scope: scope.clone(),
1727                        index: ConstraintIndex(index),
1728                    }),
1729                    range,
1730                );
1731                if let Some(constraint) = self.build_constraint(&schema, &scope, constraint, range)
1732                {
1733                    built.push(constraint);
1734                }
1735            }
1736            normalized.insert(scope, built);
1737        }
1738
1739        if !self.errors.is_empty() {
1740            return self.failure();
1741        }
1742        for (scope, constraints) in normalized {
1743            if let Some(target) = constraints_mut(&mut schema, &scope) {
1744                *target = constraints;
1745            }
1746        }
1747
1748        Ok(LoadedSchema {
1749            schema,
1750            sources: self.sources,
1751            locations: SchemaLocations {
1752                document: self.document_range,
1753                nodes: self.nodes,
1754            },
1755        })
1756    }
1757
1758    fn validate_document_shape(&mut self, value: &Value) {
1759        let Some(mapping) = value.as_object() else {
1760            self.shape_error_at(self.document_range, "schema document must be a mapping");
1761            return;
1762        };
1763        self.validate_known_fields(mapping, DOCUMENT_FIELDS, self.document_range);
1764        self.validate_required_field(mapping, "version", self.document_range);
1765        let outline_conflict = if mapping.contains_key("outline") {
1766            self.validate_outline_exclusivity(mapping)
1767        } else {
1768            self.validate_required_field(mapping, "sections", self.document_range);
1769            false
1770        };
1771
1772        if let Some(value) = mapping.get("version") {
1773            if !is_yaml_integer(value) {
1774                self.shape_error_at(
1775                    self.range(RangeKey::DocumentField("version".into())),
1776                    "version must be an integer that fits in 64 bits and cannot be null",
1777                );
1778            }
1779        }
1780        if let Some(value) = mapping.get("title") {
1781            if !matches!(value, Value::String(_) | Value::Null) {
1782                self.shape_error_at(
1783                    self.range(RangeKey::DocumentField("title".into())),
1784                    "title must be a string or null",
1785                );
1786            }
1787        }
1788        if let Some(value) = mapping.get("outline") {
1789            // On a conflict the outline forest holds no collected ranges
1790            // (`sections` keeps the shared nested-rule key space), so its
1791            // shape is left to the reload after the conflict is resolved.
1792            if !outline_conflict {
1793                let range = self.range(RangeKey::DocumentField("outline".into()));
1794                self.validate_outline_shape(value, range);
1795            }
1796        }
1797        if let Some(value) = mapping.get("options") {
1798            self.validate_options_shape(value);
1799        }
1800        if let Some(value) = mapping.get("frontmatter") {
1801            self.validate_frontmatter_shape(value);
1802        }
1803        if let Some(value) = mapping.get("sections") {
1804            let range = self.range(RangeKey::DocumentField("sections".into()));
1805            self.validate_rules_shape(value, &ScopePath(Vec::new()), range);
1806        }
1807        if let Some(value) = mapping.get("constraints") {
1808            let range = self.range(RangeKey::DocumentField("constraints".into()));
1809            self.validate_constraints_shape(value, &ScopePath(Vec::new()), range);
1810        }
1811    }
1812
1813    fn validate_frontmatter_shape(&mut self, value: &Value) {
1814        let range = self.range(RangeKey::DocumentField("frontmatter".into()));
1815        let Some(mapping) = value.as_object() else {
1816            self.shape_error_at(range, "frontmatter must be a mapping and cannot be null");
1817            return;
1818        };
1819        self.validate_known_fields(mapping, FRONTMATTER_FIELDS, range);
1820        for field in ["required", "allow"] {
1821            if let Some(value) = mapping.get(field) {
1822                if !matches!(value, Value::Bool(_)) {
1823                    self.shape_error_at(
1824                        self.range(RangeKey::FrontmatterField(field.into())),
1825                        format!("frontmatter.{field} must be a bool and cannot be null"),
1826                    );
1827                }
1828            }
1829        }
1830        if let Some(value) = mapping.get("schema") {
1831            if !matches!(value, Value::String(_) | Value::Object(_)) {
1832                self.shape_error_at(
1833                    self.range(RangeKey::FrontmatterField("schema".into())),
1834                    "frontmatter.schema must be a path string or mapping and cannot be null",
1835                );
1836            }
1837        }
1838    }
1839
1840    fn build_frontmatter(
1841        &mut self,
1842        raw: Option<RawFrontmatter>,
1843        declared: bool,
1844    ) -> Option<FrontmatterPolicy> {
1845        if !declared {
1846            return Some(FrontmatterPolicy::Optional { schema: None });
1847        }
1848        let frontmatter_range = self.range(RangeKey::DocumentField("frontmatter".into()));
1849        self.nodes
1850            .insert(SchemaNode::Frontmatter, frontmatter_range);
1851        let raw = raw?;
1852        let required = raw.required.unwrap_or(false);
1853        let allow = raw.allow.unwrap_or(true);
1854        if required && !allow {
1855            self.error_at(
1856                SchemaErrorKind::ConflictingFrontmatter,
1857                frontmatter_range,
1858                "frontmatter cannot be both required and forbidden",
1859            );
1860            return None;
1861        }
1862        let schema = match raw.schema {
1863            None => None,
1864            Some(RawFrontmatterSchema::Path(_path)) => {
1865                let schema_range = self.range(RangeKey::FrontmatterField("schema".into()));
1866                self.nodes
1867                    .insert(SchemaNode::FrontmatterSchemaDeclaration, schema_range);
1868                let Some(external) = self.external_schema.take() else {
1869                    self.error_at(
1870                        SchemaErrorKind::InvalidFrontmatterSchema,
1871                        schema_range,
1872                        "linked frontmatter schema requires a schema file path context",
1873                    );
1874                    return None;
1875                };
1876                let document_range = self.sources.documents.get(&external.root_source).map_or(
1877                    schema_range,
1878                    |source| SourceRange {
1879                        source: external.root_source,
1880                        range: TextRange {
1881                            start: ByteOffset(0),
1882                            end: ByteOffset(source.text.len()),
1883                        },
1884                    },
1885                );
1886                let schema = match external.result {
1887                    Ok(schema) => schema,
1888                    Err(errors) => {
1889                        for error in std::iter::once(errors.first).chain(errors.rest) {
1890                            let range = self.sources.documents.get(&error.source).map_or(
1891                                schema_range,
1892                                |source| SourceRange {
1893                                    source: error.source,
1894                                    range: TextRange {
1895                                        start: ByteOffset(0),
1896                                        end: ByteOffset(source.text.len()),
1897                                    },
1898                                },
1899                            );
1900                            self.error_at(
1901                                SchemaErrorKind::InvalidFrontmatterSchema,
1902                                range,
1903                                error.message,
1904                            );
1905                        }
1906                        return None;
1907                    }
1908                };
1909                self.nodes
1910                    .insert(SchemaNode::FrontmatterSchemaDocument, document_range);
1911                Some(schema)
1912            }
1913            Some(RawFrontmatterSchema::Mapping(mapping)) => {
1914                let schema_range = self.range(RangeKey::FrontmatterField("schema".into()));
1915                self.nodes
1916                    .insert(SchemaNode::FrontmatterSchemaDeclaration, schema_range);
1917                self.nodes
1918                    .insert(SchemaNode::FrontmatterSchemaDocument, schema_range);
1919                match prepare_inline_schema(mapping) {
1920                    Ok(schema) => Some(schema),
1921                    Err(errors) => {
1922                        for message in std::iter::once(errors.first).chain(errors.rest) {
1923                            self.error_at(
1924                                SchemaErrorKind::InvalidFrontmatterSchema,
1925                                schema_range,
1926                                message,
1927                            );
1928                        }
1929                        return None;
1930                    }
1931                }
1932            }
1933        };
1934        Some(if required {
1935            FrontmatterPolicy::Required { schema }
1936        } else if allow {
1937            FrontmatterPolicy::Optional { schema }
1938        } else {
1939            FrontmatterPolicy::Forbidden { schema }
1940        })
1941    }
1942
1943    fn validate_constraint_lexical_refs(&mut self) {
1944        let constraints = self.raw_constraints.clone();
1945        for (scope, values) in constraints {
1946            for (index, value) in values.iter().enumerate() {
1947                let range = self.range(RangeKey::Constraint(ConstraintPath {
1948                    scope: scope.clone(),
1949                    index: ConstraintIndex(index),
1950                }));
1951                let refs = constraint_ref_strings(value);
1952                let mut seen = HashSet::new();
1953                for reference in refs {
1954                    let valid = if reference.starts_with("fm.") {
1955                        parse_frontmatter_ref(reference).is_some()
1956                    } else {
1957                        parse_rule_ref(reference).is_some()
1958                    };
1959                    if !valid {
1960                        self.error_at(
1961                            SchemaErrorKind::UnresolvedRef,
1962                            range,
1963                            format!("invalid ref `{reference}`"),
1964                        );
1965                    }
1966                    if !seen.insert(reference) {
1967                        self.error_at(
1968                            SchemaErrorKind::DuplicateRef,
1969                            range,
1970                            format!("duplicate ref `{reference}` in one constraint"),
1971                        );
1972                    }
1973                }
1974            }
1975        }
1976    }
1977
1978    fn validate_options_shape(&mut self, value: &Value) {
1979        let range = self.range(RangeKey::DocumentField("options".into()));
1980        let Some(mapping) = value.as_object() else {
1981            self.shape_error_at(range, "options must be a mapping and cannot be null");
1982            return;
1983        };
1984        self.validate_known_fields(mapping, OPTION_FIELDS, range);
1985        for field in OPTION_FIELDS.iter().copied() {
1986            if let Some(value) = mapping.get(field) {
1987                if !matches!(value, Value::Bool(_)) {
1988                    self.shape_error_at(
1989                        self.range(RangeKey::OptionField(field.into())),
1990                        format!("options.{field} must be a bool and cannot be null"),
1991                    );
1992                }
1993            }
1994        }
1995    }
1996
1997    /// Rejects `outline` combined with either half of its sugar spelling.
1998    ///
1999    /// `title` + `sections` is defined as sugar for a single-rule `outline`,
2000    /// so a document declaring both forms has said the same thing twice and
2001    /// possibly differently. The error anchors at the second-declared key —
2002    /// the one a reader meets as the contradiction — with the first attached.
2003    fn validate_outline_exclusivity(&mut self, mapping: &JsonMap) -> bool {
2004        let outline_range = self.range(RangeKey::DocumentField("outline".into()));
2005        let mut conflict = false;
2006        for other in ["title", "sections"] {
2007            if !mapping.contains_key(other) {
2008                continue;
2009            }
2010            conflict = true;
2011            let other_range = self.range(RangeKey::DocumentField(other.into()));
2012            let outline_first = outline_range.range.start <= other_range.range.start;
2013            let (anchor, anchor_name, first, first_name) = if outline_first {
2014                (other_range, other, outline_range, "outline")
2015            } else {
2016                (outline_range, "outline", other_range, other)
2017            };
2018            self.error_with_related_at(
2019                SchemaErrorKind::ConflictingOutline,
2020                anchor,
2021                format!("`{anchor_name}` cannot be declared together with `{first_name}`"),
2022                vec![RelatedLocation {
2023                    range: first,
2024                    message: format!("`{first_name}` declared here"),
2025                }],
2026            );
2027        }
2028        conflict
2029    }
2030
2031    fn validate_outline_shape(&mut self, value: &Value, range: SourceRange) {
2032        let Some(entries) = value.as_array() else {
2033            self.shape_error_at(range, "outline must be a sequence and cannot be null");
2034            return;
2035        };
2036        for (index, value) in entries.iter().enumerate() {
2037            let rule_range = self.range(RangeKey::OutlineRule(RuleIndex(index)));
2038            let Some(mapping) = value.as_object() else {
2039                self.shape_error_at(rule_range, "each outline rule must be a mapping");
2040                continue;
2041            };
2042            self.validate_known_fields(mapping, RULE_FIELDS, rule_range);
2043            self.validate_required_field(mapping, "match", rule_range);
2044            for field in ["id", "match", "repeat"] {
2045                if let Some(value) = mapping.get(field) {
2046                    if !matches!(value, Value::String(_)) {
2047                        self.shape_error_at(
2048                            self.range(RangeKey::OutlineRuleField(RuleIndex(index), field.into())),
2049                            format!("rule `{field}` must be a string and cannot be null"),
2050                        );
2051                    }
2052                }
2053            }
2054            for field in ["allow", "required", "strict", "ordered"] {
2055                if let Some(value) = mapping.get(field) {
2056                    if !matches!(value, Value::Bool(_)) {
2057                        self.shape_error_at(
2058                            self.range(RangeKey::OutlineRuleField(RuleIndex(index), field.into())),
2059                            format!("rule `{field}` must be a bool and cannot be null"),
2060                        );
2061                    }
2062                }
2063            }
2064            let child_scope = ScopePath(vec![RuleIndex(index)]);
2065            if let Some(children) = mapping.get("sections") {
2066                let range = self.range(RangeKey::OutlineRuleField(
2067                    RuleIndex(index),
2068                    "sections".into(),
2069                ));
2070                self.validate_rules_shape(children, &child_scope, range);
2071            }
2072            if let Some(constraints) = mapping.get("constraints") {
2073                let range = self.range(RangeKey::OutlineRuleField(
2074                    RuleIndex(index),
2075                    "constraints".into(),
2076                ));
2077                self.validate_constraints_shape(constraints, &child_scope, range);
2078            }
2079        }
2080    }
2081
2082    fn validate_rules_shape(&mut self, value: &Value, scope: &ScopePath, range: SourceRange) {
2083        let Some(rules) = value.as_array() else {
2084            self.shape_error_at(range, "sections must be a sequence and cannot be null");
2085            return;
2086        };
2087        for (index, value) in rules.iter().enumerate() {
2088            let path = RulePath {
2089                scope: scope.clone(),
2090                index: RuleIndex(index),
2091            };
2092            let rule_range = self.range(RangeKey::Rule(path.clone()));
2093            let Some(mapping) = value.as_object() else {
2094                self.shape_error_at(rule_range, "each section rule must be a mapping");
2095                continue;
2096            };
2097            self.validate_known_fields(mapping, RULE_FIELDS, rule_range);
2098            self.validate_required_field(mapping, "match", rule_range);
2099            for field in ["id", "match", "repeat"] {
2100                if let Some(value) = mapping.get(field) {
2101                    if !matches!(value, Value::String(_)) {
2102                        self.shape_error_at(
2103                            self.range(RangeKey::RuleField(path.clone(), field.into())),
2104                            format!("rule `{field}` must be a string and cannot be null"),
2105                        );
2106                    }
2107                }
2108            }
2109            for field in ["allow", "required", "strict", "ordered"] {
2110                if let Some(value) = mapping.get(field) {
2111                    if !matches!(value, Value::Bool(_)) {
2112                        self.shape_error_at(
2113                            self.range(RangeKey::RuleField(path.clone(), field.into())),
2114                            format!("rule `{field}` must be a bool and cannot be null"),
2115                        );
2116                    }
2117                }
2118            }
2119            let mut child_scope = scope.clone();
2120            child_scope.0.push(RuleIndex(index));
2121            if let Some(children) = mapping.get("sections") {
2122                let range = self.range(RangeKey::RuleField(path.clone(), "sections".into()));
2123                self.validate_rules_shape(children, &child_scope, range);
2124            }
2125            if let Some(constraints) = mapping.get("constraints") {
2126                let range = self.range(RangeKey::RuleField(path, "constraints".into()));
2127                self.validate_constraints_shape(constraints, &child_scope, range);
2128            }
2129        }
2130    }
2131
2132    fn validate_constraints_shape(&mut self, value: &Value, scope: &ScopePath, range: SourceRange) {
2133        let Some(constraints) = value.as_array() else {
2134            self.shape_error_at(range, "constraints must be a sequence and cannot be null");
2135            return;
2136        };
2137        for (index, constraint) in constraints.iter().enumerate() {
2138            let range = self.range(RangeKey::Constraint(ConstraintPath {
2139                scope: scope.clone(),
2140                index: ConstraintIndex(index),
2141            }));
2142            self.validate_constraint_shape(constraint, range);
2143        }
2144    }
2145
2146    fn validate_constraint_shape(&mut self, value: &Value, range: SourceRange) {
2147        let Some(mapping) = value.as_object() else {
2148            self.shape_error_at(range, "constraint must be a single-key object");
2149            return;
2150        };
2151        if mapping.len() != 1 {
2152            self.shape_error_at(range, "constraint must contain exactly one keyword");
2153            return;
2154        }
2155        let Some((keyword, operand)) = mapping.iter().next() else {
2156            return;
2157        };
2158        match keyword.as_str() {
2159            "one_of" | "any_of" | "at_most_one" | "all_or_none" | "ordered" => {
2160                self.validate_ref_sequence(keyword, operand, true, range);
2161            }
2162            "requires" | "conflicts" => {
2163                let Some(implication) = operand.as_object() else {
2164                    self.shape_error_at(range, format!("{keyword} operand must be an object"));
2165                    return;
2166                };
2167                let consequence = if keyword == "requires" {
2168                    "then"
2169                } else {
2170                    "then_not"
2171                };
2172                let allowed = ["if", consequence];
2173                self.validate_known_fields(implication, &allowed, range);
2174                self.validate_required_field(implication, "if", range);
2175                self.validate_required_field(implication, consequence, range);
2176                if let Some(condition) = implication.get("if") {
2177                    self.validate_ref_scalar(condition, range);
2178                }
2179                if let Some(value) = implication.get(consequence) {
2180                    if value.is_array() {
2181                        self.validate_ref_sequence(consequence, value, false, range);
2182                    } else {
2183                        self.validate_ref_scalar(value, range);
2184                    }
2185                }
2186            }
2187            _ => self.shape_error_at(range, format!("unknown constraint keyword `{keyword}`")),
2188        }
2189    }
2190
2191    fn validate_ref_sequence(
2192        &mut self,
2193        name: &str,
2194        value: &Value,
2195        require_two: bool,
2196        range: SourceRange,
2197    ) {
2198        let Some(values) = value.as_array() else {
2199            self.shape_error_at(range, format!("{name} must be a sequence of refs"));
2200            return;
2201        };
2202        let minimum = if require_two { 2 } else { 1 };
2203        if values.len() < minimum {
2204            let noun = if minimum == 1 { "ref" } else { "refs" };
2205            self.shape_error_at(range, format!("{name} requires at least {minimum} {noun}"));
2206        }
2207        for value in values {
2208            self.validate_ref_scalar(value, range);
2209        }
2210    }
2211
2212    fn validate_ref_scalar(&mut self, value: &Value, range: SourceRange) {
2213        if !matches!(value, Value::String(_)) {
2214            self.shape_error_at(range, "constraint refs must be strings and cannot be null");
2215        }
2216    }
2217
2218    fn validate_known_fields(&mut self, mapping: &JsonMap, allowed: &[&str], range: SourceRange) {
2219        // A JSON object's keys are strings by construction: a YAML key that
2220        // was not one has already been rejected by the conversion, with the
2221        // key's own range.
2222        for key in mapping.keys() {
2223            if !allowed.contains(&key.as_str()) {
2224                self.shape_error_at(range, format!("unknown field `{key}`"));
2225            }
2226        }
2227    }
2228
2229    fn validate_required_field(&mut self, mapping: &JsonMap, field: &str, range: SourceRange) {
2230        if !mapping.contains_key(field) {
2231            self.shape_error_at(range, format!("missing required field `{field}`"));
2232        }
2233    }
2234
2235    /// Builds the general `outline:` form: the canonical `h1`-rule list.
2236    ///
2237    /// Outline rules are ordinary rules — `id`, `strict`, any cardinality and
2238    /// nested constraints all mean what they mean in every other scope — so
2239    /// the list is built by the same scope builder, at the empty scope the
2240    /// rules semantically live in. Only their source spelling differs, which
2241    /// [`Loader::source_key`] maps on range lookup.
2242    ///
2243    /// An empty outline is refused rather than accepted as vacuous: an empty
2244    /// rule list constrains nothing (the outline scope is open, so `h1`
2245    /// headers would pass unvalidated), while the schema author who writes it
2246    /// almost certainly means "this document has no `h1`" — which
2247    /// `title: null` declares, keeping a `sections` list for the real top
2248    /// level. Accepting `outline: []` would validate nothing and pass every
2249    /// document silently.
2250    fn build_outline_scope(
2251        &mut self,
2252        entries: Vec<RawRule>,
2253        root_scope: &ScopePath,
2254        match_case: bool,
2255        ordered_default: bool,
2256    ) -> Option<Vec<SectionRule>> {
2257        if entries.is_empty() {
2258            self.shape_error_at(
2259                self.range(RangeKey::DocumentField("outline".into())),
2260                "outline must declare at least one rule; a document with no h1 headers \
2261                 is declared with `title: null`",
2262            );
2263            return None;
2264        }
2265        self.build_scope(entries, root_scope, match_case, ordered_default)
2266    }
2267
2268    fn build_options(raw: &RawOptions) -> Options {
2269        Options {
2270            match_case: raw.match_case.unwrap_or(false),
2271            strip_inline_markup: raw.strip_inline_markup.unwrap_or(true),
2272            allow_skipped_levels: raw.allow_skipped_levels.unwrap_or(false),
2273            ordered_sections: raw.ordered_sections.unwrap_or(true),
2274        }
2275    }
2276
2277    fn build_scope(
2278        &mut self,
2279        rules: Vec<RawRule>,
2280        scope: &ScopePath,
2281        match_case: bool,
2282        ordered_default: bool,
2283    ) -> Option<Vec<SectionRule>> {
2284        let mut semantic = Vec::with_capacity(rules.len());
2285        let mut semantic_indices = Vec::with_capacity(rules.len());
2286        let mut complete = true;
2287        for (index, raw) in rules.into_iter().enumerate() {
2288            let rule_path = RulePath {
2289                scope: scope.clone(),
2290                index: RuleIndex(index),
2291            };
2292            let rule_range = self.range(RangeKey::Rule(rule_path.clone()));
2293            self.nodes
2294                .insert(SchemaNode::Rule(rule_path.clone()), rule_range);
2295            let mut child_scope = scope.clone();
2296            child_scope.0.push(RuleIndex(index));
2297            self.raw_constraints
2298                .insert(child_scope.clone(), raw.constraints);
2299
2300            let matcher_range = self.range(RangeKey::RuleField(rule_path.clone(), "match".into()));
2301            let matcher = self.build_matcher(&raw.matcher, match_case, matcher_range);
2302            let id_range = self.range(RangeKey::RuleField(
2303                rule_path.clone(),
2304                if raw.id.is_some() { "id" } else { "match" }.into(),
2305            ));
2306            let id = self.build_rule_id(raw.id.as_deref(), matcher.as_ref(), scope, id_range);
2307            let cardinality_field = if raw.repeat.is_some() {
2308                "repeat"
2309            } else if raw.required.is_some() {
2310                "required"
2311            } else {
2312                "allow"
2313            };
2314            let outcome_range = self.range(RangeKey::RuleField(
2315                rule_path.clone(),
2316                cardinality_field.into(),
2317            ));
2318            let outcome = self.build_outcome(
2319                raw.allow,
2320                raw.required,
2321                raw.repeat.as_deref(),
2322                outcome_range,
2323            );
2324            let children =
2325                self.build_scope(raw.sections, &child_scope, match_case, ordered_default);
2326            match (matcher, outcome, children) {
2327                (Some(matcher), Some(outcome), Some(sections)) => {
2328                    semantic_indices.push(index);
2329                    semantic.push(SectionRule {
2330                        id,
2331                        matcher,
2332                        outcome,
2333                        strict: raw.strict,
2334                        ordered: raw.ordered.unwrap_or(ordered_default),
2335                        sections,
2336                        constraints: Vec::new(),
2337                    });
2338                }
2339                _ => complete = false,
2340            }
2341        }
2342
2343        let mut ids: HashMap<RuleId, usize> = HashMap::new();
2344        for (&index, rule) in semantic_indices.iter().zip(&semantic) {
2345            let Some(id) = &rule.id else { continue };
2346            if let Some(first_index) = ids.get(id).copied() {
2347                let duplicate_path = RulePath {
2348                    scope: scope.clone(),
2349                    index: RuleIndex(index),
2350                };
2351                let first_path = RulePath {
2352                    scope: scope.clone(),
2353                    index: RuleIndex(first_index),
2354                };
2355                self.error_with_related_at(
2356                    SchemaErrorKind::DuplicateId,
2357                    self.rule_id_range(&duplicate_path),
2358                    format!("duplicate rule id `{}` in one scope", id.0),
2359                    vec![RelatedLocation {
2360                        range: self.rule_id_range(&first_path),
2361                        message: format!("first declared by sibling rule {first_index}"),
2362                    }],
2363                );
2364                complete = false;
2365            } else {
2366                ids.insert(id.clone(), index);
2367            }
2368        }
2369
2370        complete.then_some(semantic)
2371    }
2372
2373    fn build_rule_id(
2374        &mut self,
2375        explicit: Option<&str>,
2376        matcher: Option<&Matcher>,
2377        scope: &ScopePath,
2378        range: SourceRange,
2379    ) -> Option<RuleId> {
2380        if let Some(id) = explicit {
2381            if !is_slug(id) {
2382                self.error_at(
2383                    SchemaErrorKind::InvalidDocumentShape,
2384                    range,
2385                    format!("rule id `{id}` is not a lowercase slug"),
2386                );
2387                return None;
2388            }
2389            if scope.0.is_empty() && id == "fm" {
2390                self.error_at(
2391                    SchemaErrorKind::ReservedId,
2392                    range,
2393                    "top-level rule id `fm` is reserved for frontmatter refs",
2394                );
2395            }
2396            return Some(RuleId(id.to_owned()));
2397        }
2398
2399        let Matcher::Exact(text) = matcher? else {
2400            return None;
2401        };
2402        let generated = auto_id(&text.0).map(RuleId);
2403        if scope.0.is_empty() && generated.as_ref().is_some_and(|id| id.0 == "fm") {
2404            self.error_at(
2405                SchemaErrorKind::ReservedId,
2406                range,
2407                "top-level auto-generated rule id `fm` is reserved for frontmatter refs",
2408            );
2409        }
2410        generated
2411    }
2412
2413    fn build_matcher(
2414        &mut self,
2415        source: &str,
2416        match_case: bool,
2417        range: SourceRange,
2418    ) -> Option<Matcher> {
2419        if source == "*" {
2420            return Some(Matcher::Any);
2421        }
2422        if source.starts_with('/') && source.ends_with('/') {
2423            let Some(body) = source
2424                .strip_prefix('/')
2425                .and_then(|body| body.strip_suffix('/'))
2426            else {
2427                self.error_at(
2428                    SchemaErrorKind::InvalidMatcher,
2429                    range,
2430                    "a regex matcher needs separate opening and closing `/` delimiters",
2431                );
2432                return None;
2433            };
2434            let Some(body) = regex_body(body) else {
2435                self.error_at(
2436                    SchemaErrorKind::InvalidMatcher,
2437                    range,
2438                    format!("regex matcher `{source}` contains an unescaped `/`"),
2439                );
2440                return None;
2441            };
2442            if let Err(error) = compile_anchored_pattern(&body, match_case, false) {
2443                self.error_at(
2444                    SchemaErrorKind::InvalidMatcher,
2445                    range,
2446                    format!("invalid regex matcher `{source}`: {error}"),
2447                );
2448                return None;
2449            }
2450            return Some(Matcher::Regex(RegexPattern(body)));
2451        }
2452        if source.contains('*') {
2453            if let Err(error) = compile_glob_pattern(source, match_case) {
2454                self.error_at(
2455                    SchemaErrorKind::InvalidMatcher,
2456                    range,
2457                    format!("invalid glob matcher `{source}`: {error}"),
2458                );
2459                return None;
2460            }
2461            return Some(Matcher::Glob(GlobPattern(source.to_owned())));
2462        }
2463        Some(Matcher::Exact(ExactText(source.to_owned())))
2464    }
2465
2466    fn build_outcome(
2467        &mut self,
2468        allow: bool,
2469        required: Option<bool>,
2470        repeat: Option<&str>,
2471        range: SourceRange,
2472    ) -> Option<RuleOutcome> {
2473        if required.is_some() && repeat.is_some() {
2474            self.error_at(
2475                SchemaErrorKind::ConflictingCardinality,
2476                range,
2477                "required and repeat cannot both be declared",
2478            );
2479            return None;
2480        }
2481        if !allow && (required.is_some() || repeat.is_some()) {
2482            self.error_at(
2483                SchemaErrorKind::ConflictingCardinality,
2484                range,
2485                "allow: false cannot be combined with required or repeat",
2486            );
2487            return None;
2488        }
2489        if !allow {
2490            return Some(RuleOutcome::Deny);
2491        }
2492        let cardinality = match (required, repeat) {
2493            (Some(true), None) => Cardinality {
2494                min: 1,
2495                max: UpperBound::Bounded(1),
2496            },
2497            (Some(false), None) => Cardinality {
2498                min: 0,
2499                max: UpperBound::Bounded(1),
2500            },
2501            (None, Some(repeat)) => match parse_repeat(repeat) {
2502                Some(cardinality) => cardinality,
2503                None => {
2504                    self.error_at(
2505                        SchemaErrorKind::InvalidRepeat,
2506                        range,
2507                        format!("invalid repeat `{repeat}`"),
2508                    );
2509                    return None;
2510                }
2511            },
2512            (None, None) => Cardinality {
2513                min: 0,
2514                max: UpperBound::Unbounded,
2515            },
2516            (Some(_), Some(_)) => return None,
2517        };
2518        Some(RuleOutcome::Allow(cardinality))
2519    }
2520
2521    fn build_constraint(
2522        &mut self,
2523        schema: &Schema,
2524        scope: &ScopePath,
2525        value: Value,
2526        range: SourceRange,
2527    ) -> Option<Constraint> {
2528        let Some(mapping) = value.as_object() else {
2529            self.shape_error_at(range, "constraint must be a single-key object");
2530            return None;
2531        };
2532        if mapping.len() != 1 {
2533            self.shape_error_at(range, "constraint must contain exactly one keyword");
2534            return None;
2535        }
2536        let (keyword, operand) = mapping.iter().next()?;
2537        match keyword.as_str() {
2538            "one_of" | "any_of" | "at_most_one" | "all_or_none" => {
2539                let refs = self.parse_proposition_list(schema, scope, operand, false, range)?;
2540                let refs = at_least_two(refs).or_else(|| {
2541                    self.shape_error_at(range, format!("{keyword} requires at least two refs"));
2542                    None
2543                })?;
2544                Some(match keyword.as_str() {
2545                    "one_of" => Constraint::OneOf(refs),
2546                    "any_of" => Constraint::AnyOf(refs),
2547                    "at_most_one" => Constraint::AtMostOne(refs),
2548                    "all_or_none" => Constraint::AllOrNone(refs),
2549                    _ => return None,
2550                })
2551            }
2552            "requires" => self.build_implication(schema, scope, operand, true, range),
2553            "conflicts" => self.build_implication(schema, scope, operand, false, range),
2554            "ordered" => self.build_ordered(schema, scope, operand, range),
2555            _ => {
2556                self.shape_error_at(range, format!("unknown constraint keyword `{keyword}`"));
2557                None
2558            }
2559        }
2560    }
2561
2562    fn build_implication(
2563        &mut self,
2564        schema: &Schema,
2565        scope: &ScopePath,
2566        operand: &Value,
2567        requires: bool,
2568        range: SourceRange,
2569    ) -> Option<Constraint> {
2570        let Some(mapping) = operand.as_object() else {
2571            self.shape_error_at(range, "requires/conflicts operand must be an object");
2572            return None;
2573        };
2574        let consequence_key = if requires { "then" } else { "then_not" };
2575        if mapping.len() != 2 {
2576            self.shape_error_at(
2577                range,
2578                format!(
2579                    "{} requires exactly `if` and `{consequence_key}`",
2580                    if requires { "requires" } else { "conflicts" }
2581                ),
2582            );
2583            return None;
2584        }
2585        let Some(condition_value) = mapping.get("if") else {
2586            self.shape_error_at(range, "requires/conflicts operand is missing `if`");
2587            return None;
2588        };
2589        let Some(consequence_value) = mapping.get(consequence_key) else {
2590            self.shape_error_at(
2591                range,
2592                format!("requires/conflicts operand is missing `{consequence_key}`"),
2593            );
2594            return None;
2595        };
2596        let condition = self.parse_proposition(schema, scope, condition_value, false, range);
2597        let consequence_values = scalar_or_sequence(consequence_value);
2598        if consequence_values.is_empty() {
2599            self.shape_error_at(
2600                range,
2601                format!("`{consequence_key}` must contain at least one ref"),
2602            );
2603            return None;
2604        }
2605        let mut identities = HashSet::new();
2606        if let Some((_, identity)) = &condition {
2607            identities.insert(identity.clone());
2608        }
2609        let mut consequences = Vec::new();
2610        let mut complete = condition.is_some();
2611        for value in consequence_values {
2612            if let Some((proposition, identity)) =
2613                self.parse_proposition(schema, scope, value, false, range)
2614            {
2615                if !identities.insert(identity) {
2616                    self.error_at(
2617                        SchemaErrorKind::DuplicateRef,
2618                        range,
2619                        format!("duplicate ref in `{consequence_key}`"),
2620                    );
2621                }
2622                consequences.push(proposition);
2623            } else {
2624                complete = false;
2625            }
2626        }
2627        if !complete {
2628            return None;
2629        }
2630        let (condition, _) = condition?;
2631        let consequences = non_empty(consequences)?;
2632        Some(if requires {
2633            Constraint::Requires {
2634                condition,
2635                consequences,
2636            }
2637        } else {
2638            Constraint::Conflicts {
2639                condition,
2640                exclusions: consequences,
2641            }
2642        })
2643    }
2644
2645    fn build_ordered(
2646        &mut self,
2647        schema: &Schema,
2648        scope: &ScopePath,
2649        operand: &Value,
2650        range: SourceRange,
2651    ) -> Option<Constraint> {
2652        let values = operand.as_array().or_else(|| {
2653            self.shape_error_at(range, "ordered requires a list of refs");
2654            None
2655        })?;
2656        let mut refs = Vec::new();
2657        let mut identities = HashSet::new();
2658        let mut parent_scope: Option<Vec<usize>> = None;
2659        let mut mixed_scopes = false;
2660        let mut complete = true;
2661        for value in values {
2662            let Some((proposition, identity)) =
2663                self.parse_proposition(schema, scope, value, true, range)
2664            else {
2665                complete = false;
2666                continue;
2667            };
2668            let Proposition::Rule(rule_ref) = proposition else {
2669                self.error_at(
2670                    SchemaErrorKind::OrderedScopeMismatch,
2671                    range,
2672                    "frontmatter refs cannot be used in ordered",
2673                );
2674                continue;
2675            };
2676            let ResolvedIdentity::Rule(target) = &identity else {
2677                continue;
2678            };
2679            let Some((_, target_parent)) = target.split_last() else {
2680                continue;
2681            };
2682            let target_parent = target_parent.to_vec();
2683            if parent_scope
2684                .as_ref()
2685                .is_some_and(|existing| existing != &target_parent)
2686            {
2687                self.error_at(
2688                    SchemaErrorKind::OrderedScopeMismatch,
2689                    range,
2690                    "all ordered refs must resolve in the same scope",
2691                );
2692                mixed_scopes = true;
2693            } else {
2694                parent_scope = Some(target_parent);
2695            }
2696            if !identities.insert(identity) {
2697                self.error_at(
2698                    SchemaErrorKind::DuplicateRef,
2699                    range,
2700                    "duplicate ref in ordered",
2701                );
2702            }
2703            refs.push(rule_ref);
2704        }
2705        if !complete {
2706            return None;
2707        }
2708        // An ordered scope already orders every rule in it, so an explicit
2709        // `ordered` over that scope is either redundant — the same failure
2710        // reported twice — or contradicts the list order. When both rules in
2711        // a reversed pair are present, one of the two orders necessarily
2712        // fails; absent optional rules may satisfy both vacuously. Neither is
2713        // what the author meant, and the fix is the same either way.
2714        if !mixed_scopes
2715            && parent_scope
2716                .as_ref()
2717                .is_some_and(|target_scope| scope_is_ordered(schema, target_scope))
2718        {
2719            self.error_at(
2720                SchemaErrorKind::OrderedScopeMismatch,
2721                range,
2722                "the scope these refs resolve in is already ordered by its rule list; \
2723                 remove this constraint, or set `ordered: false` on the rule that owns \
2724                 the scope (`options.ordered_sections: false` for the top-level scope)",
2725            );
2726            return None;
2727        }
2728        let refs = at_least_two(refs).or_else(|| {
2729            self.shape_error_at(range, "ordered requires at least two refs");
2730            None
2731        })?;
2732        Some(Constraint::Ordered(refs))
2733    }
2734
2735    fn parse_proposition_list(
2736        &mut self,
2737        schema: &Schema,
2738        scope: &ScopePath,
2739        operand: &Value,
2740        ordered: bool,
2741        range: SourceRange,
2742    ) -> Option<Vec<Proposition>> {
2743        let values = operand.as_array().or_else(|| {
2744            self.shape_error_at(range, "constraint operand must be a list of refs");
2745            None
2746        })?;
2747        let mut identities = HashSet::new();
2748        let mut result = Vec::new();
2749        let mut complete = true;
2750        for value in values {
2751            if let Some((proposition, identity)) =
2752                self.parse_proposition(schema, scope, value, ordered, range)
2753            {
2754                if !identities.insert(identity) {
2755                    self.error_at(
2756                        SchemaErrorKind::DuplicateRef,
2757                        range,
2758                        "constraint contains a duplicate ref",
2759                    );
2760                }
2761                result.push(proposition);
2762            } else {
2763                complete = false;
2764            }
2765        }
2766        complete.then_some(result)
2767    }
2768
2769    fn parse_proposition(
2770        &mut self,
2771        schema: &Schema,
2772        scope: &ScopePath,
2773        value: &Value,
2774        ordered: bool,
2775        range: SourceRange,
2776    ) -> Option<(Proposition, ResolvedIdentity)> {
2777        let Some(source) = value.as_str() else {
2778            self.shape_error_at(range, "constraint refs must be strings");
2779            return None;
2780        };
2781        if source.starts_with("fm.") {
2782            let Some(reference) = parse_frontmatter_ref(source) else {
2783                self.error_at(
2784                    SchemaErrorKind::UnresolvedRef,
2785                    range,
2786                    format!("invalid frontmatter ref `{source}`"),
2787                );
2788                return None;
2789            };
2790            let identity = frontmatter_identity(&reference, schema.options.match_case);
2791            return Some((
2792                Proposition::Frontmatter(reference.clone()),
2793                ResolvedIdentity::Frontmatter(identity),
2794            ));
2795        }
2796
2797        let Some(reference) = parse_rule_ref(source) else {
2798            self.error_at(
2799                SchemaErrorKind::UnresolvedRef,
2800                range,
2801                format!("invalid or unresolved ref `{source}`"),
2802            );
2803            return None;
2804        };
2805        let Some(resolved) = resolve_ref(schema, scope, &reference) else {
2806            self.error_at(
2807                SchemaErrorKind::UnresolvedRef,
2808                range,
2809                format!("unresolved ref `{source}`"),
2810            );
2811            return None;
2812        };
2813        if resolved.denied {
2814            self.error_at(
2815                SchemaErrorKind::ForbiddenRef,
2816                range,
2817                format!("ref `{source}` passes through or targets an allow: false rule"),
2818            );
2819        }
2820        if ordered && resolved.repeated_non_final {
2821            self.error_at(
2822                SchemaErrorKind::OrderedScopeMismatch,
2823                range,
2824                format!("ordered ref `{source}` passes through a repeatable ancestor"),
2825            );
2826        }
2827        Some((
2828            Proposition::Rule(reference),
2829            ResolvedIdentity::Rule(resolved.structural_path),
2830        ))
2831    }
2832
2833    /// Reports a refusal from the YAML engine against the source's bytes.
2834    fn push_yaml_error(&mut self, error: SchemaYamlError) {
2835        let range = match error.span {
2836            Some((start, end)) => char_range(start, end, &self.char_offsets),
2837            None => self.document_range,
2838        };
2839        self.error_at(error.kind, range, error.message);
2840    }
2841
2842    fn range(&self, key: RangeKey) -> SourceRange {
2843        self.ranges.get(&self.source_key(key), self.document_range)
2844    }
2845
2846    /// Maps a semantic range key to the key its spelling was collected under.
2847    ///
2848    /// The two differ only for the general form's top-level rules: they are
2849    /// built at the empty scope but their spellings were collected under the
2850    /// dedicated outline keys. Every deeper scope, and every sugar schema,
2851    /// was collected exactly where it is built.
2852    fn source_key(&self, key: RangeKey) -> RangeKey {
2853        if !self.outline_general {
2854            return key;
2855        }
2856        match key {
2857            RangeKey::Rule(path) if path.scope.0.is_empty() => RangeKey::OutlineRule(path.index),
2858            RangeKey::RuleField(path, field) if path.scope.0.is_empty() => {
2859                RangeKey::OutlineRuleField(path.index, field)
2860            }
2861            other => other,
2862        }
2863    }
2864
2865    /// The anchor of a rule's identity: its `id` spelling, else its `match`,
2866    /// else the rule itself.
2867    fn rule_id_range(&self, path: &RulePath) -> SourceRange {
2868        for field in ["id", "match"] {
2869            let key = self.source_key(RangeKey::RuleField(path.clone(), field.into()));
2870            if let Some(range) = self.ranges.ranges.get(&key) {
2871                return *range;
2872            }
2873        }
2874        self.range(RangeKey::Rule(path.clone()))
2875    }
2876
2877    fn shape_error_at(&mut self, range: SourceRange, message: impl Into<String>) {
2878        self.error_at(SchemaErrorKind::InvalidDocumentShape, range, message);
2879    }
2880
2881    fn error_at(&mut self, kind: SchemaErrorKind, range: SourceRange, message: impl Into<String>) {
2882        self.errors.push(SchemaError {
2883            kind,
2884            range,
2885            related: Vec::new(),
2886            message: message.into(),
2887        });
2888    }
2889
2890    fn error_with_related_at(
2891        &mut self,
2892        kind: SchemaErrorKind,
2893        range: SourceRange,
2894        message: impl Into<String>,
2895        related: Vec<RelatedLocation>,
2896    ) {
2897        self.errors.push(SchemaError {
2898            kind,
2899            range,
2900            related,
2901            message: message.into(),
2902        });
2903    }
2904
2905    fn failure(mut self) -> LoadSchemaResult {
2906        if self.errors.is_empty() {
2907            self.errors.push(SchemaError {
2908                kind: SchemaErrorKind::InvalidDocumentShape,
2909                range: self.document_range,
2910                related: Vec::new(),
2911                message: "schema could not be loaded".into(),
2912            });
2913        }
2914        let first = self.errors.remove(0);
2915        Err(InvalidSchema {
2916            sources: self.sources,
2917            errors: NonEmpty {
2918                first,
2919                rest: self.errors,
2920            },
2921        })
2922    }
2923}
2924
2925#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2926enum ResolvedIdentity {
2927    Rule(Vec<usize>),
2928    Frontmatter(FrontmatterRef),
2929}
2930
2931struct ResolvedRule {
2932    structural_path: Vec<usize>,
2933    denied: bool,
2934    repeated_non_final: bool,
2935}
2936
2937fn resolve_ref(schema: &Schema, scope: &ScopePath, reference: &RuleRef) -> Option<ResolvedRule> {
2938    // Both anchors resolve from the addressed root: the outline (`h1`) scope
2939    // for the general form, the `sections` scope for sugar — so a sugar
2940    // schema's `$.` references keep meaning what they always meant, while in
2941    // an `outline` schema `$` names the `h1` rules.
2942    let (mut rules, mut structural_path) = match reference.anchor {
2943        RefAnchor::SchemaRoot => (schema.addressed_root_rules(), Vec::new()),
2944        RefAnchor::CurrentScope => (
2945            rules_at_scope(schema, scope)?,
2946            scope.0.iter().map(|index| index.0).collect(),
2947        ),
2948    };
2949    let mut denied = false;
2950    let mut repeated_non_final = false;
2951    let segment_count = reference.path.rest.len() + 1;
2952    for (position, id) in reference.path.iter().enumerate() {
2953        let (index, rule) = rules
2954            .iter()
2955            .enumerate()
2956            .find(|(_, rule)| rule.id.as_ref() == Some(id))?;
2957        structural_path.push(index);
2958        denied |= matches!(rule.outcome, RuleOutcome::Deny);
2959        if position + 1 < segment_count {
2960            repeated_non_final |= !matches!(
2961                rule.outcome,
2962                RuleOutcome::Allow(Cardinality {
2963                    max: UpperBound::Bounded(0 | 1),
2964                    ..
2965                })
2966            );
2967        }
2968        rules = &rule.sections;
2969    }
2970    Some(ResolvedRule {
2971        structural_path,
2972        denied,
2973        repeated_non_final,
2974    })
2975}
2976
2977/// Whether the scope at a structural path binds its rules in document order.
2978///
2979/// The empty path is the addressed root — the outline scope or the sugar's
2980/// `sections` scope — which follows `options.ordered_sections`, as the
2981/// synthesized title rule does.
2982fn scope_is_ordered(schema: &Schema, structural_scope: &[usize]) -> bool {
2983    let mut rules = schema.addressed_root_rules();
2984    let mut ordered = schema.options.ordered_sections;
2985    for &index in structural_scope {
2986        let Some(rule) = rules.get(index) else {
2987            return ordered;
2988        };
2989        ordered = rule.ordered;
2990        rules = &rule.sections;
2991    }
2992    ordered
2993}
2994
2995fn rules_at_scope<'a>(schema: &'a Schema, scope: &ScopePath) -> Option<&'a [SectionRule]> {
2996    let mut rules = schema.addressed_root_rules();
2997    for index in &scope.0 {
2998        let rule = rules.get(index.0)?;
2999        rules = &rule.sections;
3000    }
3001    Some(rules)
3002}
3003
3004/// The constraint list a public scope path names, in the built schema.
3005///
3006/// The empty scope names what the source's top level spelled: the outline
3007/// scope for the general form ([`Schema::constraints`]), the `sections` scope
3008/// for sugar — which is the synthesized rule's child scope, so its top-level
3009/// constraints live on that rule.
3010fn constraints_mut<'a>(
3011    schema: &'a mut Schema,
3012    scope: &ScopePath,
3013) -> Option<&'a mut Vec<Constraint>> {
3014    if schema.is_sugar() {
3015        let rule = schema.outline.first_mut()?;
3016        if scope.0.is_empty() {
3017            return Some(&mut rule.constraints);
3018        }
3019        constraints_in_rules_mut(&mut rule.sections, &scope.0)
3020    } else {
3021        if scope.0.is_empty() {
3022            return Some(&mut schema.constraints);
3023        }
3024        constraints_in_rules_mut(&mut schema.outline, &scope.0)
3025    }
3026}
3027
3028fn constraints_in_rules_mut<'a>(
3029    rules: &'a mut [SectionRule],
3030    path: &[RuleIndex],
3031) -> Option<&'a mut Vec<Constraint>> {
3032    let (index, rest) = path.split_first()?;
3033    let rule = rules.get_mut(index.0)?;
3034    if rest.is_empty() {
3035        Some(&mut rule.constraints)
3036    } else {
3037        constraints_in_rules_mut(&mut rule.sections, rest)
3038    }
3039}
3040
3041fn primary_sources(text: Arc<str>, label: Option<SourceLabel>) -> SchemaSources {
3042    SchemaSources {
3043        primary: SourceId(0),
3044        documents: BTreeMap::from([(SourceId(0), SchemaSource { label, text })]),
3045    }
3046}
3047
3048fn is_slug(value: &str) -> bool {
3049    let mut previous_hyphen = true;
3050    for byte in value.bytes() {
3051        match byte {
3052            b'a'..=b'z' | b'0'..=b'9' => previous_hyphen = false,
3053            b'-' if !previous_hyphen => previous_hyphen = true,
3054            _ => return false,
3055        }
3056    }
3057    !value.is_empty() && !previous_hyphen
3058}
3059
3060fn auto_id(value: &str) -> Option<String> {
3061    let mut result = String::new();
3062    let mut separator_pending = false;
3063    for character in value.nfkd().flat_map(char::to_lowercase) {
3064        if character.is_ascii_lowercase() || character.is_ascii_digit() {
3065            if separator_pending && !result.is_empty() {
3066                result.push('-');
3067            }
3068            result.push(character);
3069            separator_pending = false;
3070        } else if is_combining_mark(character) {
3071            // NFKD splits letters such as `ä` into an ASCII base followed by
3072            // a combining mark. The mark modifies that base; it is not a word
3073            // boundary and therefore must not introduce a slug separator.
3074        } else {
3075            separator_pending = true;
3076        }
3077    }
3078    (!result.is_empty()).then_some(result)
3079}
3080
3081fn regex_body(source: &str) -> Option<String> {
3082    let mut result = String::with_capacity(source.len());
3083    let mut characters = source.chars();
3084    while let Some(character) = characters.next() {
3085        if character == '/' {
3086            return None;
3087        }
3088        if character != '\\' {
3089            result.push(character);
3090            continue;
3091        }
3092        match characters.next() {
3093            Some('/') => result.push('/'),
3094            Some(next) => {
3095                result.push('\\');
3096                result.push(next);
3097            }
3098            None => result.push('\\'),
3099        }
3100    }
3101    Some(result)
3102}
3103
3104fn parse_repeat(source: &str) -> Option<Cardinality> {
3105    let (min, max) = source.split_once("..")?;
3106    if min.is_empty() || max.is_empty() || max.contains("..") || !valid_decimal(min) {
3107        return None;
3108    }
3109    let min = min.parse::<u32>().ok()?;
3110    let max = if max == "n" {
3111        UpperBound::Unbounded
3112    } else {
3113        if !valid_decimal(max) {
3114            return None;
3115        }
3116        let max = max.parse::<u32>().ok()?;
3117        if max < min || max == 0 {
3118            return None;
3119        }
3120        UpperBound::Bounded(max)
3121    };
3122    Some(Cardinality { min, max })
3123}
3124
3125fn valid_decimal(value: &str) -> bool {
3126    value == "0"
3127        || value
3128            .strip_prefix(|character: char| ('1'..='9').contains(&character))
3129            .is_some_and(|rest| rest.bytes().all(|byte| byte.is_ascii_digit()))
3130}
3131
3132fn parse_rule_ref(source: &str) -> Option<RuleRef> {
3133    let (anchor, path) = if let Some(path) = source.strip_prefix("$.") {
3134        (RefAnchor::SchemaRoot, path)
3135    } else {
3136        (RefAnchor::CurrentScope, source)
3137    };
3138    let mut segments = path.split('.');
3139    let first = segments.next()?;
3140    if !is_slug(first) {
3141        return None;
3142    }
3143    let rest = segments
3144        .map(|segment| is_slug(segment).then(|| RuleId(segment.to_owned())))
3145        .collect::<Option<Vec<_>>>()?;
3146    Some(RuleRef {
3147        anchor,
3148        path: NonEmpty {
3149            first: RuleId(first.to_owned()),
3150            rest,
3151        },
3152    })
3153}
3154
3155fn parse_frontmatter_ref(source: &str) -> Option<FrontmatterRef> {
3156    let body = source.strip_prefix("fm.")?;
3157    let (path, equals) = match body.split_once('=') {
3158        Some((path, literal)) => (path, Some(parse_frontmatter_scalar(literal))),
3159        None => (body, None),
3160    };
3161    let mut keys = path.split('.');
3162    let first = keys.next()?;
3163    if first.is_empty() {
3164        return None;
3165    }
3166    let rest = keys
3167        .map(|key| (!key.is_empty()).then(|| FrontmatterKey(key.to_owned())))
3168        .collect::<Option<Vec<_>>>()?;
3169    Some(FrontmatterRef {
3170        path: NonEmpty {
3171            first: FrontmatterKey(first.to_owned()),
3172            rest,
3173        },
3174        equals,
3175    })
3176}
3177
3178fn frontmatter_identity(reference: &FrontmatterRef, match_case: bool) -> FrontmatterRef {
3179    let mut identity = reference.clone();
3180    if !match_case {
3181        if let Some(FrontmatterScalar::String(value)) = &mut identity.equals {
3182            *value = crate::case_fold::simple_fold(value).collect();
3183        }
3184    }
3185    identity
3186}
3187
3188pub(crate) fn parse_frontmatter_scalar(source: &str) -> FrontmatterScalar {
3189    match source {
3190        "" | "~" | "null" | "Null" | "NULL" => FrontmatterScalar::Null,
3191        "true" | "True" | "TRUE" => FrontmatterScalar::Boolean(true),
3192        "false" | "False" | "FALSE" => FrontmatterScalar::Boolean(false),
3193        _ => {
3194            if let Some(integer) = canonical_integer(source) {
3195                FrontmatterScalar::Integer(CanonicalInteger(integer))
3196            } else if let Some(float) = canonical_float(source) {
3197                FrontmatterScalar::Float(CanonicalFloat(float))
3198            } else {
3199                FrontmatterScalar::String(source.to_owned())
3200            }
3201        }
3202    }
3203}
3204
3205fn canonical_integer(source: &str) -> Option<String> {
3206    let (negative, unsigned) = strip_sign(source);
3207    let (base, digits) = if let Some(digits) = unsigned.strip_prefix("0o") {
3208        (8_u8, digits)
3209    } else if let Some(digits) = unsigned.strip_prefix("0x") {
3210        (16, digits)
3211    } else {
3212        (10, unsigned)
3213    };
3214    if digits.is_empty() {
3215        return None;
3216    }
3217    let value = BigUint::parse_bytes(digits.as_bytes(), u32::from(base))?;
3218    if value == BigUint::from(0_u8) {
3219        return Some("0".into());
3220    }
3221    Some(format!("{}{value}", if negative { "-" } else { "" }))
3222}
3223
3224pub(crate) fn canonical_float(source: &str) -> Option<String> {
3225    let (negative, unsigned) = strip_sign(source);
3226    if matches!(unsigned, ".inf" | ".Inf" | ".INF") {
3227        return Some(if negative { "-inf" } else { "inf" }.into());
3228    }
3229    if matches!(unsigned, ".nan" | ".NaN" | ".NAN") {
3230        return (source == unsigned).then(|| "nan".into());
3231    }
3232    let (mantissa, exponent) = unsigned.split_once(['e', 'E']).unwrap_or((unsigned, "0"));
3233    let has_float_marker = mantissa.contains('.') || unsigned.contains(['e', 'E']);
3234    if !has_float_marker {
3235        return None;
3236    }
3237    let exponent = exponent.parse::<BigInt>().ok()?;
3238    let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
3239    if whole.is_empty() && fraction.is_empty() {
3240        return None;
3241    }
3242    if !whole.bytes().all(|byte| byte.is_ascii_digit())
3243        || !fraction.bytes().all(|byte| byte.is_ascii_digit())
3244    {
3245        return None;
3246    }
3247    let digits = format!("{whole}{fraction}");
3248    let trimmed_leading = digits.trim_start_matches('0');
3249    if trimmed_leading.is_empty() {
3250        return Some("0e0".into());
3251    }
3252    let trailing = trimmed_leading.len() - trimmed_leading.trim_end_matches('0').len();
3253    let coefficient = trimmed_leading.trim_end_matches('0');
3254    let adjusted = exponent - BigInt::from(fraction.len()) + BigInt::from(trailing);
3255    Some(format!(
3256        "{}{coefficient}e{adjusted}",
3257        if negative { "-" } else { "" }
3258    ))
3259}
3260
3261fn strip_sign(source: &str) -> (bool, &str) {
3262    if let Some(unsigned) = source.strip_prefix('-') {
3263        (true, unsigned)
3264    } else if let Some(unsigned) = source.strip_prefix('+') {
3265        (false, unsigned)
3266    } else {
3267        (false, source)
3268    }
3269}
3270
3271/// Whether a value is an integer the schema's own fields can hold.
3272///
3273/// The engine preserves a number's exact spelling, so an integer of any
3274/// magnitude arrives here as a number rather than failing the parse; one that
3275/// does not fit the 64-bit fields is a shape complaint against the value, not
3276/// a syntax error against the document.
3277fn is_yaml_integer(value: &Value) -> bool {
3278    match value {
3279        Value::Number(number) => number.as_i64().is_some() || number.as_u64().is_some(),
3280        _ => false,
3281    }
3282}
3283
3284fn scalar_or_sequence(value: &Value) -> Vec<&Value> {
3285    value
3286        .as_array()
3287        .map_or_else(|| vec![value], |values| values.iter().collect())
3288}
3289
3290fn constraint_ref_strings(value: &Value) -> Vec<&str> {
3291    let Some(mapping) = value.as_object() else {
3292        return Vec::new();
3293    };
3294    let Some((keyword, operand)) = mapping.iter().next() else {
3295        return Vec::new();
3296    };
3297    match keyword.as_str() {
3298        "one_of" | "any_of" | "at_most_one" | "all_or_none" | "ordered" => operand
3299            .as_array()
3300            .into_iter()
3301            .flatten()
3302            .filter_map(Value::as_str)
3303            .collect(),
3304        "requires" | "conflicts" => {
3305            let Some(implication) = operand.as_object() else {
3306                return Vec::new();
3307            };
3308            let consequence = if keyword == "requires" {
3309                "then"
3310            } else {
3311                "then_not"
3312            };
3313            let mut result = implication
3314                .get("if")
3315                .and_then(Value::as_str)
3316                .into_iter()
3317                .collect::<Vec<_>>();
3318            if let Some(value) = implication.get(consequence) {
3319                result.extend(
3320                    scalar_or_sequence(value)
3321                        .into_iter()
3322                        .filter_map(Value::as_str),
3323                );
3324            }
3325            result
3326        }
3327        _ => Vec::new(),
3328    }
3329}
3330
3331fn non_empty<T>(mut values: Vec<T>) -> Option<NonEmpty<T>> {
3332    if values.is_empty() {
3333        return None;
3334    }
3335    let first = values.remove(0);
3336    Some(NonEmpty {
3337        first,
3338        rest: values,
3339    })
3340}
3341
3342fn at_least_two<T>(mut values: Vec<T>) -> Option<AtLeastTwo<T>> {
3343    if values.len() < 2 {
3344        return None;
3345    }
3346    let first = values.remove(0);
3347    let second = values.remove(0);
3348    Some(AtLeastTwo {
3349        first,
3350        second,
3351        rest: values,
3352    })
3353}
3354
3355#[cfg(test)]
3356mod tests {
3357    use super::*;
3358    use proptest::prelude::*;
3359
3360    fn valid(source: &str) -> Schema {
3361        match load_schema(source) {
3362            Ok(loaded) => loaded.schema,
3363            Err(invalid) => panic!("unexpected errors: {:#?}", invalid.errors),
3364        }
3365    }
3366
3367    fn error_kinds(source: &str) -> Vec<SchemaErrorKind> {
3368        match load_schema(source) {
3369            Ok(loaded) => panic!("unexpected valid schema: {:#?}", loaded.schema),
3370            Err(invalid) => invalid.errors.iter().map(|error| error.kind).collect(),
3371        }
3372    }
3373
3374    fn invalid(source: &str) -> InvalidSchema {
3375        match load_schema(source) {
3376            Ok(loaded) => panic!("unexpected valid schema: {:#?}", loaded.schema),
3377            Err(invalid) => invalid,
3378        }
3379    }
3380
3381    fn source_slice(source: &str, range: SourceRange) -> &str {
3382        source
3383            .get(range.range.start.0..range.range.end.0)
3384            .unwrap_or("<invalid range>")
3385    }
3386
3387    #[test]
3388    fn yaml_syntax_error_ranges_convert_character_columns_to_bytes() {
3389        for source in [
3390            "version: 1\ntitle: å: bad\nsections: []\n",
3391            "version: 1\ntitle: a: bad\nsections: []\n",
3392            "version: 1\rtitle: å: bad\rsections: []\r",
3393        ] {
3394            let invalid = load_schema(source).expect_err("schema has invalid YAML");
3395            let error = &invalid.errors.first;
3396            let expected_start = source
3397                .find(": bad")
3398                .unwrap_or_else(|| panic!("test source contains the bad colon"));
3399
3400            assert_eq!(error.kind, SchemaErrorKind::Syntax);
3401            assert_eq!(error.range.range.start, ByteOffset(expected_start));
3402            assert_eq!(source_slice(source, error.range), ":");
3403            assert!(source.is_char_boundary(error.range.range.start.0));
3404            assert!(source.is_char_boundary(error.range.range.end.0));
3405        }
3406    }
3407
3408    #[test]
3409    fn a_second_schema_document_is_refused_at_its_own_start_marker() {
3410        // The refusal lands on the second `---` before any of that document's
3411        // content is read — raw `next_event` does not clear the anchor table
3412        // between documents — and it carries the marker's real span, where the
3413        // serde-era engine could only anchor the whole document. The `---`
3414        // sits in the first column, so this doubles as the pin that a
3415        // first-column range survives the character-to-byte conversion.
3416        for (source, line) in [
3417            (
3418                "version: 1\nsections: []\n---\nversion: 1\nsections: []\n",
3419                3,
3420            ),
3421            ("version: 1\nsections: []\n...\n---\nsections: []\n", 4),
3422        ] {
3423            let invalid = invalid(source);
3424            assert_eq!(invalid.errors.first.kind, SchemaErrorKind::Syntax);
3425            assert_eq!(
3426                invalid.errors.first.message,
3427                format!(
3428                    "invalid YAML: a second document opens at line {line} column 1; \
3429                     a schema is a single YAML document"
3430                )
3431            );
3432            assert_eq!(source_slice(source, invalid.errors.first.range), "---");
3433            let start = invalid.errors.first.range.range.start.0;
3434            assert!(
3435                start == 0 || source.as_bytes()[start - 1] == b'\n',
3436                "the `---` anchor must sit in the first column"
3437            );
3438        }
3439
3440        // A `...` that closes the only document opens nothing.
3441        assert_eq!(
3442            valid("version: 1\nsections: []\n...\n")
3443                .addressed_root_rules()
3444                .len(),
3445            0
3446        );
3447    }
3448
3449    #[test]
3450    fn a_merge_key_is_an_ordinary_schema_field() {
3451        // `<<` belongs to YAML's optional merge type, not to the core schema,
3452        // and no parser this crate reads applies it. A schema author who writes
3453        // one therefore gets an unknown field named `<<` rather than the fields
3454        // of the mapping they aliased. Pinned rather than fixed: honoring merges
3455        // would make schemas that are rejected today start loading, which needs
3456        // a specification first.
3457        let source =
3458            "version: 1\nbase: &b\n  strip_inline_markup: true\noptions:\n  <<: *b\nsections: []\n";
3459        let invalid = invalid(source);
3460        let reported = invalid
3461            .errors
3462            .iter()
3463            .map(|error| (error.kind, error.message.as_str()))
3464            .collect::<Vec<_>>();
3465        assert_eq!(
3466            reported,
3467            vec![
3468                (
3469                    SchemaErrorKind::InvalidDocumentShape,
3470                    "unknown field `base`"
3471                ),
3472                (SchemaErrorKind::InvalidDocumentShape, "unknown field `<<`"),
3473            ],
3474        );
3475    }
3476
3477    /// A schema of `rules` rules, each the sole child of the one above it.
3478    ///
3479    /// Nesting is what a schema spends YAML depth on, two levels per rule: the
3480    /// `sections` sequence and the rule mapping it holds.
3481    fn nested_rule_schema(rules: usize) -> String {
3482        let mut source = String::from("version: 1\n");
3483        for rule in 0..rules {
3484            let indent = "  ".repeat(rule * 2);
3485            source.push_str(&format!(
3486                "{indent}sections:\n{indent}  - match: \"h{rule}\"\n"
3487            ));
3488        }
3489        source
3490    }
3491
3492    #[test]
3493    fn schema_nesting_is_bounded() {
3494        // The reader charges the depth limit as its own recursion descends, so
3495        // a schema nesting past it is refused at the exact node that would
3496        // overrun the stack, before that node is built. Two levels per rule
3497        // plus the document's own mapping puts the deepest schema that fits at
3498        // 63 rules, and the first that does not at 64 — the boundary the
3499        // serde-era engine's identical limit drew.
3500        let schema = valid(&nested_rule_schema(63));
3501        assert_eq!(schema.addressed_root_rules().len(), 1);
3502
3503        for rules in [64, 5_000] {
3504            let source = nested_rule_schema(rules);
3505            let invalid = invalid(&source);
3506            assert_eq!(invalid.errors.first.kind, SchemaErrorKind::Syntax);
3507            assert_eq!(
3508                invalid.errors.first.message,
3509                "invalid YAML: nesting exceeds the depth limit"
3510            );
3511            // The refusal is anchored where the 64th rule's mapping opens —
3512            // its first key — however much deeper the document goes on, since
3513            // nothing past the refusal is read.
3514            let overrun = source
3515                .match_indices("match")
3516                .nth(63)
3517                .map(|(offset, _)| offset)
3518                .expect("the fixture spells one `match` per rule");
3519            assert_eq!(invalid.errors.first.range.range.start, ByteOffset(overrun));
3520        }
3521    }
3522
3523    /// A schema whose `constraints` entries chain anchors, each wrapping an
3524    /// alias to the entry above it in one more sequence.
3525    ///
3526    /// Every entry is one flow sequence in the source, so no event stream ever
3527    /// shows more than three open collections, while the tree the reader
3528    /// builds reaches `links` levels below the `constraints` sequence once
3529    /// the aliases are expanded.
3530    fn alias_deepened_schema(links: usize) -> String {
3531        let mut source =
3532            String::from("version: 1\nsections:\n  - match: Title\nconstraints:\n  - &x0 [1]\n");
3533        for line in 1..links {
3534            source.push_str(&format!("  - &x{line} [*x{}]\n", line - 1));
3535        }
3536        source
3537    }
3538
3539    #[test]
3540    fn alias_expanded_schema_nesting_is_bounded_only_by_the_readers_own_limit() {
3541        // Depth an alias splices in is depth no event stream shows: an alias
3542        // is one event however deep the value it names. The reader therefore
3543        // charges an alias the whole depth of the node it copies — before the
3544        // clone — exactly as the frontmatter reader does. This guard used to
3545        // live inside `yaml_serde`; the frontmatter path once dropped that
3546        // dependency without replacing what it supplied (ec565c6, 25 GB of
3547        // RSS, two commits to recover), and this pin is what makes the same
3548        // loss loud on the schema path. The 127-link fixture is shallow
3549        // enough to build harmlessly were the guard gone, at which point the
3550        // loader would walk it to a constraint-shape complaint and the
3551        // message assertions below would fail plainly.
3552        //
3553        // The boundary from both sides: at 126 links the expanded tree fills
3554        // the limit of 128 exactly (root mapping, `constraints` sequence, 126
3555        // chained levels) and is built — proven by the loader getting past
3556        // parsing to reject the entries as constraints — and one more link
3557        // flips the outcome to an ordinary syntax diagnostic anchored at the
3558        // alias that splices the overrun in, not a crash. The boundary is the
3559        // one `yaml_serde`'s recursion limit drew before the port.
3560        let at_limit = alias_deepened_schema(126);
3561        let built = invalid(&at_limit);
3562        assert_eq!(
3563            built.errors.first.kind,
3564            SchemaErrorKind::InvalidDocumentShape
3565        );
3566        assert_eq!(
3567            built.errors.first.message,
3568            "constraint must be a single-key object"
3569        );
3570
3571        for links in [127, 2_000] {
3572            let source = alias_deepened_schema(links);
3573            let refused = invalid(&source);
3574            assert_eq!(refused.errors.first.kind, SchemaErrorKind::Syntax);
3575            assert_eq!(
3576                refused.errors.first.message,
3577                "invalid YAML: nesting exceeds the depth limit"
3578            );
3579            // The reported position is the alias whose expansion would pass
3580            // the limit, however many further links the chain spells.
3581            assert_eq!(source_slice(&source, refused.errors.first.range), "*x125");
3582            // The same engine serves linked-schema discovery, which reports
3583            // the refused document as declaring no linked schema.
3584            assert_eq!(linked_frontmatter_schema_path(&source), None);
3585        }
3586    }
3587
3588    /// A schema whose every `x` entry aliases the one above it four times.
3589    ///
3590    /// The `depth + 1` short lines this writes name `4 ^ (depth + 1)` leaf
3591    /// scalars between them; nothing nests deeply, so only the node budget
3592    /// stops it — the same shape the frontmatter bomb fixtures pin.
3593    fn alias_bomb_schema(depth: usize) -> String {
3594        let mut bomb = String::from("version: 1\nsections: []\nx0: &x0 [1,1,1,1]\n");
3595        for level in 1..=depth {
3596            let alias = format!("*x{}", level - 1);
3597            bomb.push_str(&format!(
3598                "x{level}: &x{level} [{alias},{alias},{alias},{alias}]\n"
3599            ));
3600        }
3601        bomb
3602    }
3603
3604    #[test]
3605    fn schema_alias_expansion_is_bounded_by_the_node_budget() {
3606        // The wall clock is part of the assertion: a loader that expands the
3607        // bomb before refusing it returns the right verdict a gigabyte too
3608        // late, which is the regression the budget exists to prevent.
3609        for depth in [9, 12, 15] {
3610            let bomb = alias_bomb_schema(depth);
3611            let started = std::time::Instant::now();
3612            let refused = invalid(&bomb);
3613            let elapsed = started.elapsed();
3614            assert_eq!(refused.errors.first.kind, SchemaErrorKind::Syntax);
3615            assert_eq!(
3616                refused.errors.first.message,
3617                "invalid YAML: alias expansion exceeds the document's size limit"
3618            );
3619            // The refusal lands on the alias whose copy overruns the budget.
3620            assert!(source_slice(&bomb, refused.errors.first.range).starts_with("*x"));
3621            assert!(
3622                elapsed < std::time::Duration::from_secs(1),
3623                "an alias bomb at depth {depth} took {elapsed:?}, \
3624                 so it was expanded before being refused"
3625            );
3626        }
3627
3628        // Ordinary reuse stays far under the budget: the aliased matcher is
3629        // copied once and the schema loads.
3630        let schema =
3631            valid("version: 1\nsections:\n  - match: &m Intro\n  - id: other\n    match: *m\n");
3632        assert_eq!(schema.addressed_root_rules().len(), 2);
3633    }
3634
3635    #[test]
3636    fn non_standard_tags_are_rejected_anywhere_in_a_schema_document() {
3637        // Judgment call: a tag outside the yaml.org namespace has no meaning a
3638        // schema could use, and the serde-era engine rejected such documents
3639        // too. The refusal is uniform — scalar, collection, or the document's
3640        // own root — where the old engine incidentally accepted a root tag.
3641        let scalar = invalid("version: 1\ntitle: !custom Doc\nsections: []\n");
3642        assert_eq!(scalar.errors.first.kind, SchemaErrorKind::Syntax);
3643        assert_eq!(
3644            scalar.errors.first.message,
3645            "invalid YAML: non-standard tag `!custom`"
3646        );
3647        assert_eq!(
3648            source_slice(
3649                "version: 1\ntitle: !custom Doc\nsections: []\n",
3650                scalar.errors.first.range
3651            ),
3652            "Doc"
3653        );
3654
3655        let root = invalid("--- !custom\nversion: 1\nsections: []\n");
3656        assert_eq!(root.errors.first.kind, SchemaErrorKind::Syntax);
3657        assert_eq!(
3658            root.errors.first.message,
3659            "invalid YAML: non-standard tag `!custom`"
3660        );
3661
3662        // Core-schema tags keep their meaning.
3663        let schema = valid("version: !!int 1\ntitle: !!str Doc\nsections: []\n");
3664        assert!(matches!(
3665            schema.outline.first().map(|rule| &rule.matcher),
3666            Some(Matcher::Exact(_))
3667        ));
3668    }
3669
3670    #[test]
3671    fn a_standard_tag_on_a_schema_collection_must_name_the_collection_kind() {
3672        // This verdict changed in the saphyr port: the serde-era engine
3673        // ignored a mismatched standard tag on a schema collection, so
3674        // `sections: !!map` over a block sequence loaded as if untagged. The
3675        // shared container-tag check now refuses the mismatch — the same rule
3676        // the frontmatter path applies — and this test records the new
3677        // behaviour deliberately. A tag that names the collection's own kind
3678        // keeps loading on both engines.
3679        let schema = valid("version: 1\nsections: !!seq\n  - match: A\n");
3680        assert_eq!(schema.addressed_root_rules().len(), 1);
3681
3682        let source = "version: 1\nsections: !!map\n  - match: A\n";
3683        let refused = invalid(source);
3684        assert_eq!(refused.errors.first.kind, SchemaErrorKind::Syntax);
3685        assert_eq!(
3686            refused.errors.first.message,
3687            "invalid YAML: invalid tag for a YAML seq"
3688        );
3689        // The refusal anchors where the sequence starts: the first entry's
3690        // `-` at 3:3.
3691        assert_eq!(source_slice(source, refused.errors.first.range), "-");
3692        assert_eq!(refused.errors.first.range.range.start, ByteOffset(29));
3693    }
3694
3695    #[test]
3696    fn an_oversized_version_is_a_shape_error_at_the_value() {
3697        // The engine preserves a number's exact spelling, so an integer of any
3698        // magnitude parses; one that does not fit the schema's own 64-bit
3699        // field is now a shape complaint against the value — the serde-era
3700        // engine refused the whole parse as a syntax error instead.
3701        let source = "version: 99999999999999999999999999\nsections: []\n";
3702        let invalid = invalid(source);
3703        assert_eq!(
3704            invalid.errors.first.kind,
3705            SchemaErrorKind::InvalidDocumentShape
3706        );
3707        assert_eq!(
3708            invalid.errors.first.message,
3709            "version must be an integer that fits in 64 bits and cannot be null"
3710        );
3711        assert_eq!(
3712            source_slice(source, invalid.errors.first.range),
3713            "99999999999999999999999999"
3714        );
3715    }
3716
3717    #[test]
3718    fn one_leading_byte_order_mark_is_removed_before_parsing() {
3719        // Left in place, the mark becomes the first character of the first
3720        // key, and the loader rejects the document naming a `version` field
3721        // the author cannot see is misspelled. Exactly one is removed — the
3722        // same rule the frontmatter path applies — and every reported range
3723        // counts it back in, so a second mark stays visible.
3724        let schema = valid("\u{feff}version: 1\nsections: []\n");
3725        assert_eq!(schema.version, SchemaVersion::V1);
3726
3727        let source = "\u{feff}\u{feff}version: 1\nsections: []\n";
3728        let doubled = invalid(source);
3729        assert!(doubled
3730            .errors
3731            .iter()
3732            .any(|error| error.message == "unknown field `\u{feff}version`"));
3733    }
3734
3735    #[test]
3736    fn duplicate_keys_are_rejected_on_resolved_text_at_the_duplicate() {
3737        // `a` and `"a"` are one key however differently they are spelled; the
3738        // refusal names the key and anchors at the duplicate occurrence.
3739        for source in [
3740            "version: 1\nversion: 2\nsections: []\n",
3741            "version: 1\n\"version\": 2\nsections: []\n",
3742        ] {
3743            let refused = invalid(source);
3744            assert_eq!(refused.errors.first.kind, SchemaErrorKind::Syntax);
3745            assert_eq!(
3746                refused.errors.first.message,
3747                "invalid YAML: duplicate mapping key `version`"
3748            );
3749            assert!(refused.errors.first.range.range.start >= ByteOffset(11));
3750        }
3751    }
3752
3753    #[test]
3754    fn applies_defaults_and_normalizes_rules() {
3755        let schema = valid(
3756            r#"
3757version: 1
3758sections:
3759  - match: API Reference
3760    required: true
3761  - id: api
3762    match: "/API: .+/"
3763    repeat: 0..n
3764  - match: "*"
3765    allow: false
3766"#,
3767        );
3768        assert!(!schema.options.match_case);
3769        assert!(schema.options.strip_inline_markup);
3770        assert!(!schema.options.allow_skipped_levels);
3771        let rules = schema.addressed_root_rules();
3772        assert_eq!(rules[0].id, Some(RuleId("api-reference".into())));
3773        assert_eq!(
3774            rules[0].outcome,
3775            RuleOutcome::Allow(Cardinality {
3776                min: 1,
3777                max: UpperBound::Bounded(1)
3778            })
3779        );
3780        assert!(matches!(rules[2].outcome, RuleOutcome::Deny));
3781    }
3782
3783    #[test]
3784    fn classifies_matcher_forms_and_unescapes_regex_delimiter() {
3785        let schema = valid(
3786            r#"
3787version: 1
3788sections:
3789  - match: exact
3790  - match: prefix*suffix
3791  - match: "*"
3792  - match: /a\/b/
3793"#,
3794        );
3795        let rules = schema.addressed_root_rules();
3796        assert!(matches!(rules[0].matcher, Matcher::Exact(_)));
3797        assert!(matches!(rules[1].matcher, Matcher::Glob(_)));
3798        assert_eq!(rules[2].matcher, Matcher::Any);
3799        assert_eq!(rules[3].matcher, Matcher::Regex(RegexPattern("a/b".into())));
3800    }
3801
3802    #[test]
3803    fn rejects_invalid_regex_and_repeat_while_collecting_errors() {
3804        let kinds = error_kinds(
3805            r#"
3806version: 1
3807sections:
3808  - match: /(?=lookaround)/
3809    repeat: 01..2
3810  - match: ok
3811    allow: false
3812    required: true
3813"#,
3814        );
3815        assert!(kinds.contains(&SchemaErrorKind::InvalidMatcher));
3816        assert!(kinds.contains(&SchemaErrorKind::InvalidRepeat));
3817        assert!(kinds.contains(&SchemaErrorKind::ConflictingCardinality));
3818    }
3819
3820    #[test]
3821    fn rejects_a_single_regex_delimiter_without_panicking() {
3822        let kinds = error_kinds(
3823            r#"
3824version: 1
3825sections:
3826  - match: "/"
3827"#,
3828        );
3829        assert_eq!(kinds, vec![SchemaErrorKind::InvalidMatcher]);
3830    }
3831
3832    #[test]
3833    fn regex_load_validation_uses_the_normalized_match_case_setting() {
3834        let body = "[a-z]{100000}";
3835        let case_insensitive = format!("version: 1\nsections:\n  - match: \"/{body}/\"\n");
3836        let invalid = load_schema(&case_insensitive)
3837            .expect_err("case-insensitive compiled regex exceeds the size limit");
3838        assert_eq!(invalid.errors.first.kind, SchemaErrorKind::InvalidMatcher);
3839
3840        let case_sensitive = format!(
3841            "version: 1\noptions:\n  match_case: true\nsections:\n  - match: \"/{body}/\"\n"
3842        );
3843        let loaded = load_schema(&case_sensitive).expect("the same regex fits when case-sensitive");
3844        crate::PreparedValidator::new(&loaded.schema)
3845            .expect("loader and validator use identical case-sensitive settings");
3846    }
3847
3848    #[test]
3849    fn oversized_glob_is_invalid_at_its_matcher_range_and_errors_are_collected() {
3850        let glob = format!("{}*", "a".repeat(200_000));
3851        let source = format!("version: 1\nsections:\n  - match: {glob}\n    repeat: 01..2\n");
3852        let invalid = load_schema(&source).expect_err("oversized glob must fail during loading");
3853        let errors = invalid.errors.iter().collect::<Vec<_>>();
3854
3855        assert_eq!(errors.len(), 2);
3856        assert_eq!(errors[0].kind, SchemaErrorKind::InvalidMatcher);
3857        assert_eq!(source_slice(&source, errors[0].range), glob);
3858        assert_eq!(errors[1].kind, SchemaErrorKind::InvalidRepeat);
3859
3860        let case_sensitive =
3861            format!("version: 1\noptions:\n  match_case: true\nsections:\n  - match: {glob}\n");
3862        let loaded = load_schema(&case_sensitive)
3863            .expect("the same glob fits when matching case-sensitively");
3864        crate::PreparedValidator::new(&loaded.schema)
3865            .expect("loader and validator use identical case-sensitive glob settings");
3866    }
3867
3868    #[test]
3869    fn detects_auto_id_collisions_per_scope() {
3870        let kinds = error_kinds(
3871            r#"
3872version: 1
3873sections:
3874  - match: API
3875  - id: api
3876    match: Something else
3877"#,
3878        );
3879        assert!(kinds.contains(&SchemaErrorKind::DuplicateId));
3880    }
3881
3882    #[test]
3883    fn auto_ids_discard_decomposed_marks_without_splitting_words() {
3884        assert_eq!(auto_id("Mälardalen"), Some("malardalen".to_owned()));
3885        assert_eq!(auto_id("nai\u{308}ve café"), Some("naive-cafe".to_owned()));
3886        assert_eq!(auto_id("a—b"), Some("a-b".to_owned()));
3887    }
3888
3889    #[test]
3890    fn rejects_auto_generated_reserved_fm_id() {
3891        let kinds = error_kinds(
3892            r#"
3893version: 1
3894sections:
3895  - match: fm
3896"#,
3897        );
3898        assert_eq!(kinds, vec![SchemaErrorKind::ReservedId]);
3899    }
3900
3901    /// `root_level` was removed from the format: the title is always the `h1`
3902    /// and `sections` always describes `h2`. A schema still declaring it is
3903    /// rejected as an unknown option rather than silently ignored.
3904    #[test]
3905    fn rejects_the_removed_root_level_option() {
3906        let source = "version: 1\noptions:\n  root_level: 3\nsections: []\n";
3907        let invalid = invalid(source);
3908        let messages = invalid
3909            .errors
3910            .iter()
3911            .map(|error| (error.kind, error.message.clone()))
3912            .collect::<Vec<_>>();
3913        assert_eq!(
3914            messages,
3915            vec![(
3916                SchemaErrorKind::InvalidDocumentShape,
3917                "unknown field `root_level`".to_owned()
3918            )]
3919        );
3920    }
3921
3922    #[test]
3923    fn rejects_every_explicit_null_typed_field_and_collects_them() {
3924        // `title: null` is the one legal null: it declares a document with no
3925        // h1, so only the four other nulls are rejected.
3926        let source = r#"version: 1
3927title: null
3928options:
3929  match_case: null
3930sections:
3931  - id: null
3932    match: valid
3933    required: null
3934    repeat: null
3935"#;
3936        let invalid = invalid(source);
3937        let errors = invalid.errors.iter().collect::<Vec<_>>();
3938        assert_eq!(errors.len(), 4);
3939        assert!(errors
3940            .iter()
3941            .all(|error| error.kind == SchemaErrorKind::InvalidDocumentShape
3942                && source_slice(source, error.range) == "null"));
3943        let mut actual = errors
3944            .iter()
3945            .map(|error| error.range.range.start.0)
3946            .collect::<Vec<_>>();
3947        actual.sort_unstable();
3948        let expected = source
3949            .match_indices("null")
3950            .map(|(offset, _)| offset)
3951            .skip(1)
3952            .collect::<Vec<_>>();
3953        assert_eq!(actual, expected);
3954    }
3955
3956    #[test]
3957    fn title_null_declares_a_document_without_h1() {
3958        let source = "version: 1\ntitle: null\nsections:\n  - match: Overview\n";
3959        let loaded = load_schema(source).expect("title: null loads");
3960        // The declaration desugars to a denied any-text h1 rule carrying the
3961        // `sections` scope: a present h1 is not-allowed, and the sections
3962        // describe the document's top-level h2s.
3963        let rule = &loaded.schema.outline[0];
3964        assert_eq!(rule.matcher, Matcher::Any);
3965        assert_eq!(rule.outcome, RuleOutcome::Deny);
3966        assert_eq!(rule.sections.len(), 1);
3967        assert_eq!(loaded.schema.outline_provenance, OutlineProvenance::NoTitle);
3968        assert_eq!(
3969            source_slice(
3970                source,
3971                *loaded
3972                    .locations
3973                    .nodes
3974                    .get(&SchemaNode::Title)
3975                    .expect("title: null anchors the title node")
3976            ),
3977            "null"
3978        );
3979    }
3980
3981    #[test]
3982    fn sugar_forms_carry_their_provenance() {
3983        let titled = valid("version: 1\ntitle: Doc\nsections: []\n");
3984        assert_eq!(titled.outline_provenance, OutlineProvenance::Title);
3985        let bare = valid("version: 1\nsections: []\n");
3986        assert_eq!(bare.outline_provenance, OutlineProvenance::BareSections);
3987    }
3988
3989    #[test]
3990    fn outline_rules_are_the_canonical_model_and_anchor_at_their_spellings() {
3991        let source = r#"version: 1
3992outline:
3993  - match: Part
3994    required: true
3995    sections:
3996      - match: Overview
3997        required: true
3998"#;
3999        let loaded = load_schema(source).expect("a single-rule outline loads");
4000        let schema = &loaded.schema;
4001        assert_eq!(schema.outline_provenance, OutlineProvenance::Outline);
4002        assert_eq!(
4003            schema.outline[0].matcher,
4004            Matcher::Exact(ExactText("Part".into()))
4005        );
4006        assert_eq!(
4007            schema.outline[0].outcome,
4008            RuleOutcome::Allow(Cardinality {
4009                min: 1,
4010                max: UpperBound::Bounded(1)
4011            })
4012        );
4013        assert_eq!(
4014            schema.outline[0].sections[0].matcher,
4015            Matcher::Exact(ExactText("Overview".into()))
4016        );
4017        // The outline rule is an ordinary rule at the empty scope; its child
4018        // anchors one scope below. There is no title node: nothing in this
4019        // schema is a title.
4020        assert!(!loaded.locations.nodes.contains_key(&SchemaNode::Title));
4021        assert_eq!(
4022            source_slice(
4023                source,
4024                *loaded
4025                    .locations
4026                    .nodes
4027                    .get(&SchemaNode::Rule(RulePath {
4028                        scope: ScopePath(Vec::new()),
4029                        index: RuleIndex(0),
4030                    }))
4031                    .expect("the outline rule is the root scope's first rule")
4032            ),
4033            "match: Part\n    required: true\n    sections:\n      - match: Overview\n        required: true\n"
4034        );
4035        assert_eq!(
4036            source_slice(
4037                source,
4038                *loaded
4039                    .locations
4040                    .nodes
4041                    .get(&SchemaNode::Rule(RulePath {
4042                        scope: ScopePath(vec![RuleIndex(0)]),
4043                        index: RuleIndex(0),
4044                    }))
4045                    .expect("the outline rule's child sits one scope below")
4046            ),
4047            "match: Overview\n        required: true\n"
4048        );
4049    }
4050
4051    #[test]
4052    fn sugar_and_outline_forms_parse_to_the_same_model() {
4053        let sugar = valid(
4054            r#"version: 1
4055title: "Doc *"
4056sections:
4057  - match: Overview
4058    required: true
4059    sections:
4060      - match: Details
4061  - match: Second
4062constraints:
4063  - any_of: [overview, second]
4064"#,
4065        );
4066        let general = valid(
4067            r#"version: 1
4068outline:
4069  - match: "Doc *"
4070    required: true
4071    sections:
4072      - match: Overview
4073        required: true
4074        sections:
4075          - match: Details
4076      - match: Second
4077    constraints:
4078      - any_of: [overview, second]
4079"#,
4080        );
4081        assert_eq!(sugar.outline_provenance, OutlineProvenance::Title);
4082        assert_eq!(general.outline_provenance, OutlineProvenance::Outline);
4083        let mut general_as_sugar = general;
4084        general_as_sugar.outline_provenance = OutlineProvenance::Title;
4085        assert_eq!(sugar, general_as_sugar);
4086    }
4087
4088    #[test]
4089    fn an_outline_declares_any_number_of_ordinary_h1_rules() {
4090        let schema = valid(
4091            r#"version: 1
4092outline:
4093  - match: "Part *"
4094    repeat: "1..n"
4095  - id: appendix
4096    match: Appendix
4097    strict: true
4098"#,
4099        );
4100        assert_eq!(schema.outline.len(), 2);
4101        assert_eq!(
4102            schema.outline[0].outcome,
4103            RuleOutcome::Allow(Cardinality {
4104                min: 1,
4105                max: UpperBound::Unbounded
4106            })
4107        );
4108        assert_eq!(schema.outline[1].id, Some(RuleId("appendix".into())));
4109        assert!(schema.outline[1].strict);
4110    }
4111
4112    #[test]
4113    fn an_empty_outline_is_refused_toward_title_null() {
4114        // `outline: []` would constrain nothing — the outline scope is open,
4115        // so h1 headers would pass unvalidated — while its author almost
4116        // certainly means "no h1", which `title: null` declares.
4117        let invalid = invalid("version: 1\noutline: []\n");
4118        assert_eq!(
4119            invalid.errors.first.message,
4120            "outline must declare at least one rule; a document with no h1 headers \
4121             is declared with `title: null`"
4122        );
4123    }
4124
4125    #[test]
4126    fn outline_conflicts_with_title_at_the_second_declared_key() {
4127        let source = "version: 1\ntitle: Doc\noutline:\n  - match: Doc\n    required: true\n";
4128        let invalid = invalid(source);
4129        let errors = invalid.errors.iter().collect::<Vec<_>>();
4130        assert_eq!(errors.len(), 1);
4131        let error = errors[0];
4132        assert_eq!(error.kind, SchemaErrorKind::ConflictingOutline);
4133        assert_eq!(
4134            error.message,
4135            "`outline` cannot be declared together with `title`"
4136        );
4137        assert_eq!(
4138            source_slice(source, error.range),
4139            "- match: Doc\n    required: true\n"
4140        );
4141        assert_eq!(error.related.len(), 1);
4142        assert_eq!(source_slice(source, error.related[0].range), "Doc");
4143        assert_eq!(error.related[0].message, "`title` declared here");
4144    }
4145
4146    #[test]
4147    fn outline_conflicts_with_sections_anchoring_whichever_comes_second() {
4148        // `outline` first: the error anchors at `sections`.
4149        let source = "version: 1\noutline:\n  - match: Doc\n    required: true\nsections: []\n";
4150        let invalid = invalid(source);
4151        let errors = invalid.errors.iter().collect::<Vec<_>>();
4152        assert_eq!(errors.len(), 1);
4153        let error = errors[0];
4154        assert_eq!(error.kind, SchemaErrorKind::ConflictingOutline);
4155        assert_eq!(
4156            error.message,
4157            "`sections` cannot be declared together with `outline`"
4158        );
4159        assert_eq!(source_slice(source, error.range), "[]");
4160        assert_eq!(error.related[0].message, "`outline` declared here");
4161    }
4162
4163    #[test]
4164    fn top_level_constraints_beside_outline_attach_to_the_h1_scope() {
4165        // Their refs resolve among the outline rules themselves.
4166        let schema = valid(
4167            "version: 1\noptions:\n  ordered_sections: false\noutline:\n  - id: intro\n\
4168             \x20   match: Intro\n  - id: body\n    match: Body\nconstraints:\n\
4169             \x20 - ordered: [intro, body]\n",
4170        );
4171        assert_eq!(schema.constraints.len(), 1);
4172        assert!(schema
4173            .outline
4174            .iter()
4175            .all(|rule| rule.constraints.is_empty()));
4176
4177        // A sugar schema's top-level constraints attach to the `sections`
4178        // scope instead — the desugared rule's child scope — leaving the
4179        // schema-level list empty.
4180        let sugar = valid(
4181            "version: 1\noptions:\n  ordered_sections: false\nsections:\n  - id: a\n\
4182             \x20   match: A\n  - id: b\n    match: B\nconstraints:\n  - ordered: [a, b]\n",
4183        );
4184        assert!(sugar.constraints.is_empty());
4185        assert_eq!(sugar.outline[0].constraints.len(), 1);
4186    }
4187
4188    #[test]
4189    fn schema_root_refs_anchor_at_the_outline_scope_in_the_general_form() {
4190        // `$` names the h1 rules for `outline:` schemas; a sugar schema's
4191        // `$.` refs keep resolving against its `sections` scope.
4192        let schema = valid(
4193            "version: 1\noutline:\n  - id: doc\n    match: Doc\n    required: true\n\
4194             \x20   sections:\n      - id: a\n        match: A\n        constraints:\n\
4195             \x20         - requires: { if: \"$.doc.a\", then: \"$.doc\" }\n",
4196        );
4197        assert_eq!(schema.outline[0].sections[0].constraints.len(), 1);
4198        // The same spelling that resolved through `sections` before still
4199        // does: `$.a` in sugar reaches the top-level `sections` rule.
4200        let sugar = valid(
4201            "version: 1\nsections:\n  - id: a\n    match: A\n    sections:\n\
4202             \x20     - id: b\n        match: B\n    constraints:\n\
4203             \x20     - requires: { if: b, then: \"$.a\" }\n",
4204        );
4205        assert_eq!(sugar.outline[0].sections[0].constraints.len(), 1);
4206        // An unresolved `$.` ref in the general form is a real error, not a
4207        // gate: `$.a` skips the outline level.
4208        let unresolved = invalid(
4209            "version: 1\noutline:\n  - id: doc\n    match: Doc\n    required: true\n\
4210             \x20   sections:\n      - id: a\n        match: A\n    constraints:\n\
4211             \x20     - requires: { if: a, then: \"$.a\" }\n",
4212        );
4213        assert!(unresolved
4214            .errors
4215            .iter()
4216            .any(|error| error.kind == SchemaErrorKind::UnresolvedRef
4217                && error.message == "unresolved ref `$.a`"));
4218    }
4219
4220    #[test]
4221    fn outline_rules_take_every_cardinality_spelling() {
4222        let schema = valid("version: 1\noutline:\n  - match: Doc\n    repeat: \"1..1\"\n");
4223        assert_eq!(
4224            schema.outline[0].outcome,
4225            RuleOutcome::Allow(Cardinality {
4226                min: 1,
4227                max: UpperBound::Bounded(1)
4228            })
4229        );
4230        // No cardinality at all is the ordinary open default.
4231        let default = valid("version: 1\noutline:\n  - match: Doc\n");
4232        assert_eq!(
4233            default.outline[0].outcome,
4234            RuleOutcome::Allow(Cardinality {
4235                min: 0,
4236                max: UpperBound::Unbounded
4237            })
4238        );
4239    }
4240
4241    #[test]
4242    fn errors_inside_an_outline_rule_anchor_at_their_own_spellings() {
4243        let source = r#"version: 1
4244outline:
4245  - match: Doc
4246    required: true
4247    sections:
4248      - match: "/(/"
4249"#;
4250        let invalid = invalid(source);
4251        let regex = invalid
4252            .errors
4253            .iter()
4254            .find(|error| error.kind == SchemaErrorKind::InvalidMatcher)
4255            .expect("the child rule's regex is invalid");
4256        assert_eq!(source_slice(source, regex.range), "\"/(/\"");
4257    }
4258
4259    #[test]
4260    fn constraints_on_an_outline_rule_anchor_at_their_own_spellings() {
4261        let source = r#"version: 1
4262outline:
4263  - match: Doc
4264    required: true
4265    sections:
4266      - match: Overview
4267    constraints:
4268      - one_of: [missing, alike]
4269"#;
4270        let invalid = invalid(source);
4271        let unresolved = invalid
4272            .errors
4273            .iter()
4274            .find(|error| error.kind == SchemaErrorKind::UnresolvedRef)
4275            .expect("the constraint refs do not resolve");
4276        assert_eq!(
4277            source_slice(source, unresolved.range),
4278            "one_of: [missing, alike]\n"
4279        );
4280    }
4281
4282    #[test]
4283    fn ordered_refs_through_a_repeatable_h1_rule_are_refused() {
4284        // §5.1 at the outline level: an ordered ref whose path crosses a
4285        // repeatable ancestor has no single document position to compare, so
4286        // `Part` under `repeat: 1..n` cannot carry an ordered ref path.
4287        let invalid = invalid(
4288            "version: 1\noutline:\n  - id: part\n    match: \"Part *\"\n    repeat: \"1..n\"\n\
4289             \x20   sections:\n      - id: a\n        match: A\n      - id: b\n        match: B\n\
4290             constraints:\n  - ordered: [part.a, part.b]\n",
4291        );
4292        assert!(invalid
4293            .errors
4294            .iter()
4295            .any(|error| error.kind == SchemaErrorKind::OrderedScopeMismatch));
4296    }
4297
4298    #[test]
4299    fn duplicate_id_error_and_related_location_point_to_each_scalar() {
4300        let source = r#"version: 1
4301sections:
4302  - id: duplicate
4303    match: First
4304  - id: duplicate
4305    match: Second
4306"#;
4307        let invalid = invalid(source);
4308        let error = invalid
4309            .errors
4310            .iter()
4311            .find(|error| error.kind == SchemaErrorKind::DuplicateId)
4312            .unwrap_or_else(|| panic!("missing duplicate-id error"));
4313        assert_eq!(source_slice(source, error.range), "duplicate");
4314        assert_eq!(error.related.len(), 1);
4315        assert_eq!(source_slice(source, error.related[0].range), "duplicate");
4316        assert_ne!(error.range.range.start, error.related[0].range.range.start);
4317    }
4318
4319    #[test]
4320    fn successful_node_locations_are_narrower_than_the_document() {
4321        let source = r#"version: 1
4322options:
4323  ordered_sections: false
4324title: "*"
4325sections:
4326  - match: Overview
4327  - match: Details
4328constraints:
4329  - ordered: [overview, details]
4330"#;
4331        let loaded = match load_schema(source) {
4332            Ok(loaded) => loaded,
4333            Err(invalid) => panic!("unexpected errors: {:#?}", invalid.errors),
4334        };
4335        let addresses = [
4336            SchemaNode::Title,
4337            SchemaNode::Rule(RulePath {
4338                scope: ScopePath(Vec::new()),
4339                index: RuleIndex(0),
4340            }),
4341            SchemaNode::Constraint(ConstraintPath {
4342                scope: ScopePath(Vec::new()),
4343                index: ConstraintIndex(0),
4344            }),
4345        ];
4346        for address in addresses {
4347            let range = loaded
4348                .locations
4349                .nodes
4350                .get(&address)
4351                .copied()
4352                .unwrap_or_else(|| panic!("missing range for {address:?}"));
4353            assert!(range.range.start > loaded.locations.document.range.start);
4354            assert!(range.range.end <= loaded.locations.document.range.end);
4355            assert!(range.range.start < range.range.end);
4356            assert_ne!(range, loaded.locations.document);
4357        }
4358    }
4359
4360    #[test]
4361    fn repeat_accepts_u32_boundary_and_rejects_overflow() {
4362        let schema = valid(
4363            r#"
4364version: 1
4365sections:
4366  - match: many
4367    repeat: 4294967295..4294967295
4368"#,
4369        );
4370        assert_eq!(
4371            schema.addressed_root_rules()[0].outcome,
4372            RuleOutcome::Allow(Cardinality {
4373                min: u32::MAX,
4374                max: UpperBound::Bounded(u32::MAX)
4375            })
4376        );
4377        let kinds = error_kinds(
4378            r#"
4379version: 1
4380sections:
4381  - match: too-many
4382    repeat: 4294967296..n
4383"#,
4384        );
4385        assert_eq!(kinds, vec![SchemaErrorKind::InvalidRepeat]);
4386    }
4387
4388    #[test]
4389    fn external_source_ids_report_exhaustion_instead_of_saturating() {
4390        assert_eq!(external_source_id(0), Some(SourceId(1)));
4391        #[cfg(target_pointer_width = "64")]
4392        assert_eq!(external_source_id(u32::MAX as usize), None);
4393    }
4394
4395    #[test]
4396    fn resolves_constraints_and_normalizes_frontmatter_scalars() {
4397        let schema = valid(
4398            r#"
4399version: 1
4400sections:
4401  - match: Overview
4402    sections:
4403      - match: Goals
4404  - match: Deployment
4405constraints:
4406  - requires: { if: deployment, then: [$.overview.goals, fm.count=0x10] }
4407"#,
4408        );
4409        let Constraint::Requires { consequences, .. } = &schema.outline[0].constraints[0] else {
4410            panic!("expected requires")
4411        };
4412        assert_eq!(
4413            consequences.rest[0],
4414            Proposition::Frontmatter(FrontmatterRef {
4415                path: NonEmpty {
4416                    first: FrontmatterKey("count".into()),
4417                    rest: vec![]
4418                },
4419                equals: Some(FrontmatterScalar::Integer(CanonicalInteger("16".into())))
4420            })
4421        );
4422    }
4423
4424    #[test]
4425    fn frontmatter_ref_identity_uses_simple_case_folding() {
4426        let duplicate = error_kinds(
4427            r#"
4428version: 1
4429sections: []
4430constraints:
4431  - any_of: [fm.key=ſ, fm.key=S]
4432"#,
4433        );
4434        assert_eq!(duplicate, vec![SchemaErrorKind::DuplicateRef]);
4435
4436        let schema = valid(
4437            r#"
4438version: 1
4439sections: []
4440constraints:
4441  - any_of: [fm.key=ß, fm.key=ss]
4442"#,
4443        );
4444        assert_eq!(schema.outline[0].constraints.len(), 1);
4445    }
4446
4447    #[test]
4448    fn rejects_dangling_forbidden_duplicate_and_mis_scoped_ordered_refs() {
4449        let kinds = error_kinds(
4450            r#"
4451version: 1
4452sections:
4453  - id: repeated
4454    match: Repeated
4455    sections:
4456      - match: Child
4457  - id: denied
4458    match: Denied
4459    allow: false
4460constraints:
4461  - any_of: [missing, missing]
4462  - requires: { if: denied, then: denied }
4463  - ordered: [repeated.child, denied]
4464"#,
4465        );
4466        assert!(kinds.contains(&SchemaErrorKind::UnresolvedRef));
4467        assert!(kinds.contains(&SchemaErrorKind::ForbiddenRef));
4468        assert!(kinds.contains(&SchemaErrorKind::OrderedScopeMismatch));
4469    }
4470
4471    #[test]
4472    fn checks_constraint_lexemes_even_when_a_rule_cannot_be_built() {
4473        let kinds = error_kinds(
4474            r#"
4475version: 1
4476sections:
4477  - match: /(?=invalid)/
4478constraints:
4479  - any_of: [bad..ref, also..bad]
4480"#,
4481        );
4482        assert_eq!(
4483            kinds,
4484            vec![
4485                SchemaErrorKind::InvalidMatcher,
4486                SchemaErrorKind::UnresolvedRef,
4487                SchemaErrorKind::UnresolvedRef
4488            ]
4489        );
4490    }
4491
4492    #[test]
4493    fn yaml_core_scalars_support_arbitrary_magnitude_without_signed_nan() {
4494        assert_eq!(
4495            parse_frontmatter_scalar("1e100000000000000000000000000000000000000"),
4496            FrontmatterScalar::Float(CanonicalFloat(
4497                "1e100000000000000000000000000000000000000".into()
4498            ))
4499        );
4500        assert_eq!(
4501            parse_frontmatter_scalar("-0xffffffffffffffffffffffffffffffff"),
4502            FrontmatterScalar::Integer(CanonicalInteger(
4503                "-340282366920938463463374607431768211455".into()
4504            ))
4505        );
4506        assert_eq!(
4507            parse_frontmatter_scalar("-.nan"),
4508            FrontmatterScalar::String("-.nan".into())
4509        );
4510        assert_eq!(
4511            parse_frontmatter_scalar("+.NaN"),
4512            FrontmatterScalar::String("+.NaN".into())
4513        );
4514    }
4515
4516    #[test]
4517    fn normalizes_frontmatter_presence_policy() {
4518        let schema = valid(
4519            r#"
4520version: 1
4521frontmatter: { required: true }
4522sections: []
4523"#,
4524        );
4525        assert_eq!(
4526            schema.frontmatter,
4527            FrontmatterPolicy::Required { schema: None }
4528        );
4529    }
4530
4531    #[test]
4532    fn inline_frontmatter_schema_validates_and_preserves_primary_source_provenance() {
4533        let source = r#"version: 1
4534frontmatter:
4535  schema:
4536    type: object
4537    required: [status]
4538    properties:
4539      status: { enum: [draft, final] }
4540title: null
4541sections: []
4542"#;
4543        let loaded = load_schema(source).expect("inline JSON Schema is valid");
4544        let declaration = loaded.locations.nodes[&SchemaNode::FrontmatterSchemaDeclaration];
4545        let document = loaded.locations.nodes[&SchemaNode::FrontmatterSchemaDocument];
4546        assert_eq!(declaration, document);
4547        assert_eq!(declaration.source, SourceId(0));
4548        assert_eq!(
4549            &source[declaration.range.start.0..declaration.range.end.0],
4550            "type: object\n    required: [status]\n    properties:\n      status: { enum: [draft, final] }\n"
4551        );
4552
4553        let document = crate::parse_markdown(
4554            "---\nstatus: review\n---\n",
4555            crate::MarkdownOptions::default(),
4556        );
4557        let diagnostics =
4558            crate::validate(&loaded.schema, &document).expect("inline schema compiles again");
4559        assert_eq!(diagnostics.len(), 1);
4560        assert_eq!(diagnostics[0].id, crate::DiagnosticId::FrontmatterSchema);
4561        let crate::DiagnosticTarget::Frontmatter { block: Some(block) } = &diagnostics[0].target
4562        else {
4563            panic!("frontmatter schema diagnostic must target its block")
4564        };
4565        assert_eq!(block.json_pointer.as_deref(), Some("/status"));
4566    }
4567
4568    #[test]
4569    fn inline_frontmatter_schema_accepts_fragment_references_and_cycles() {
4570        let loaded = inline(
4571            r##"{
4572                "$ref": "#/$defs/node",
4573                "$defs": {
4574                    "node": {
4575                        "type": "object",
4576                        "properties": { "child": { "$ref": "#/$defs/node" } }
4577                    }
4578                }
4579            }"##,
4580        )
4581        .expect("fragment-only recursive schema is valid");
4582        let document = crate::parse_markdown(
4583            "---\nchild:\n  child: false\n---\n",
4584            crate::MarkdownOptions::default(),
4585        );
4586        let diagnostics =
4587            crate::validate(&loaded.schema, &document).expect("recursive inline schema compiles");
4588        assert_eq!(diagnostics.len(), 1);
4589        assert_eq!(diagnostics[0].id, crate::DiagnosticId::FrontmatterSchema);
4590
4591        let loaded = inline(
4592            r##"{
4593                "$defs": {
4594                    "node": {
4595                        "$dynamicAnchor": "node",
4596                        "type": "object",
4597                        "properties": { "child": { "$dynamicRef": "#node" } }
4598                    }
4599                },
4600                "properties": { "node": { "$ref": "#/$defs/node" } }
4601            }"##,
4602        )
4603        .expect("fragment-only dynamic reference is valid");
4604        let document = crate::parse_markdown(
4605            "---\nnode:\n  child: false\n---\n",
4606            crate::MarkdownOptions::default(),
4607        );
4608        let diagnostics = crate::validate(&loaded.schema, &document)
4609            .expect("dynamic fragment reference validates without retrieval");
4610        assert_eq!(diagnostics.len(), 1);
4611        assert_eq!(diagnostics[0].id, crate::DiagnosticId::FrontmatterSchema);
4612    }
4613
4614    #[test]
4615    fn inline_frontmatter_schema_reserves_reference_members_in_every_object() {
4616        for (root, expected) in [
4617            (r#"{"const":{"$ref":"literal"}}"#, "fragment-only"),
4618            (r#"{"properties":{"$ref":"literal"}}"#, "fragment-only"),
4619            (
4620                r#"{"unknown":{"$dynamicRef":17}}"#,
4621                "must be a string beginning with `#`",
4622            ),
4623        ] {
4624            let invalid = inline(root).expect_err("reserved member is checked lexically");
4625            assert_eq!(invalid.errors.iter().count(), 1);
4626            assert_eq!(
4627                invalid.errors.first.kind,
4628                SchemaErrorKind::InvalidFrontmatterSchema
4629            );
4630            assert!(invalid.errors.first.message.contains(expected));
4631        }
4632    }
4633
4634    #[test]
4635    fn inline_reference_walk_covers_every_object_member() {
4636        let root = serde_json::json!({
4637            "$defs": { "defined": { "$ref": "defined.json" } },
4638            "properties": { "property": { "$ref": "property.json" } },
4639            "patternProperties": { ".*": { "$ref": "pattern.json" } },
4640            "dependentSchemas": { "key": { "$ref": "dependent.json" } },
4641            "unevaluatedProperties": { "$ref": "unevaluated-properties.json" },
4642            "unevaluatedItems": { "$ref": "unevaluated-items.json" },
4643            "if": { "$ref": "if.json" },
4644            "then": { "$ref": "then.json" },
4645            "else": { "$ref": "else.json" },
4646            "const": { "$ref": "literal.json" },
4647            "enum": [{ "$dynamicRef": "literal.json" }],
4648            "unknown": { "$ref": "unknown.json" }
4649        });
4650        assert_eq!(invalid_inline_references(&root).len(), 12);
4651        assert_eq!(json_schema_reference_count(&root), 12);
4652    }
4653
4654    #[test]
4655    fn inline_frontmatter_schema_rejects_pointer_hidden_external_references() {
4656        let invalid = inline(
4657            r##"{
4658                "$ref": "#/const",
4659                "const": { "$ref": "https://example.invalid/hidden.json" }
4660            }"##,
4661        )
4662        .expect_err("a pointer-targeted object cannot hide an external reference");
4663        assert_eq!(invalid.errors.iter().count(), 1);
4664        assert!(invalid.errors.first.message.contains("fragment-only"));
4665        assert!(invalid.errors.first.message.contains("hidden.json"));
4666    }
4667
4668    #[test]
4669    fn inline_frontmatter_schema_relative_ids_resolve_from_the_synthetic_base() {
4670        let root_id = inline(
4671            r##"{
4672                "$id": "schemas/root.json",
4673                "$defs": { "status": { "enum": ["draft", "final"] } },
4674                "properties": { "status": { "$ref": "#/$defs/status" } }
4675            }"##,
4676        )
4677        .expect("a root relative id resolves against the hierarchical base");
4678        let document = crate::parse_markdown(
4679            "---\nstatus: review\n---\n",
4680            crate::MarkdownOptions::default(),
4681        );
4682        assert_eq!(
4683            crate::validate(&root_id.schema, &document)
4684                .expect("root relative id compiles")
4685                .len(),
4686            1
4687        );
4688
4689        let nested_id = inline(
4690            r##"{
4691                "$defs": {
4692                    "node": {
4693                        "$id": "nested/node.json",
4694                        "type": "object",
4695                        "properties": { "child": { "$ref": "#" } }
4696                    }
4697                },
4698                "properties": { "node": { "$ref": "#/$defs/node" } }
4699            }"##,
4700        )
4701        .expect("a nested relative id resolves against the hierarchical base");
4702        let document = crate::parse_markdown(
4703            "---\nnode:\n  child: false\n---\n",
4704            crate::MarkdownOptions::default(),
4705        );
4706        assert_eq!(
4707            crate::validate(&nested_id.schema, &document)
4708                .expect("nested relative id compiles")
4709                .len(),
4710            1
4711        );
4712    }
4713
4714    #[test]
4715    fn inline_frontmatter_schema_enforces_the_supported_dialect_and_meta_schema() {
4716        inline(
4717            r#"{
4718                "$schema": "https://json-schema.org/draft/2020-12/schema",
4719                "type": "object"
4720            }"#,
4721        )
4722        .expect("draft 2020-12 is supported inline");
4723
4724        for root in [
4725            r#"{"$schema":"http://json-schema.org/draft-07/schema#"}"#,
4726            r#"{"type":17}"#,
4727        ] {
4728            let invalid = inline(root).expect_err("unsupported or malformed schema is invalid");
4729            assert_eq!(
4730                invalid.errors.first.kind,
4731                SchemaErrorKind::InvalidFrontmatterSchema
4732            );
4733            assert_eq!(invalid.errors.first.range.source, SourceId(0));
4734        }
4735    }
4736
4737    #[test]
4738    fn inline_frontmatter_schema_rejects_non_fragment_references() {
4739        for (keyword, reference) in [
4740            ("$ref", "defs.json#/$defs/value"),
4741            ("$ref", "/schemas/defs.json"),
4742            ("$ref", "file:///schemas/defs.json"),
4743            ("$ref", "https://example.invalid/schema.json"),
4744            ("$dynamicRef", "defs.json#node"),
4745        ] {
4746            let source = serde_json::json!({ keyword: reference }).to_string();
4747            let invalid = inline(&source).expect_err("external inline reference is invalid");
4748            assert_eq!(invalid.errors.iter().count(), 1);
4749            assert_eq!(
4750                invalid.errors.first.kind,
4751                SchemaErrorKind::InvalidFrontmatterSchema
4752            );
4753            assert_eq!(invalid.errors.first.range.source, SourceId(0));
4754            assert!(invalid.errors.first.message.contains("fragment-only"));
4755            assert!(invalid.errors.first.message.contains(reference));
4756            let range = invalid.errors.first.range.range;
4757            let primary = &invalid.sources.documents[&SourceId(0)].text;
4758            assert_eq!(&primary[range.start.0..range.end.0], source);
4759        }
4760    }
4761
4762    #[test]
4763    fn inline_frontmatter_schema_rejects_non_string_reference_values() {
4764        for value in [serde_json::Value::Null, serde_json::json!(17)] {
4765            let source = serde_json::json!({ "$ref": value }).to_string();
4766            let invalid = inline(&source).expect_err("reference must be a string");
4767            assert_eq!(invalid.errors.iter().count(), 1);
4768            assert_eq!(
4769                invalid.errors.first.message,
4770                "inline frontmatter JSON Schema `$ref` must be a string beginning with `#`"
4771            );
4772        }
4773    }
4774
4775    #[test]
4776    fn inline_frontmatter_schema_uses_the_shared_reference_budget() {
4777        let at_budget = hidden_reference_chain(MAX_JSON_SCHEMA_REFERENCES);
4778        assert_eq!(
4779            json_schema_reference_count(&at_budget),
4780            MAX_JSON_SCHEMA_REFERENCES
4781        );
4782        inline(&at_budget.to_string()).expect("a pointer-hidden chain may spend the whole budget");
4783        linked(&at_budget.to_string(), &[])
4784            .expect("the linked budget admits the same exact boundary");
4785
4786        let over_budget = hidden_reference_chain(MAX_JSON_SCHEMA_REFERENCES + 1);
4787        let invalid =
4788            inline(&over_budget.to_string()).expect_err("one hidden reference more is refused");
4789        assert_eq!(invalid.errors.iter().count(), 1);
4790        assert_eq!(
4791            invalid.errors.first.message,
4792            json_schema_reference_budget_message()
4793        );
4794        assert_eq!(invalid.errors.first.range.source, SourceId(0));
4795
4796        let linked_invalid = linked(&over_budget.to_string(), &[])
4797            .expect_err("linked graphs count pointer-hidden references too");
4798        assert_eq!(
4799            linked_invalid.errors.first.message,
4800            json_schema_reference_budget_message()
4801        );
4802        assert_eq!(linked_invalid.errors.first.range.source, SourceId(1));
4803    }
4804
4805    #[test]
4806    fn linked_frontmatter_schema_requires_file_context() {
4807        let kinds = error_kinds(
4808            r#"
4809version: 1
4810frontmatter: { schema: frontmatter.schema.json }
4811sections: []
4812"#,
4813        );
4814        assert_eq!(kinds, vec![SchemaErrorKind::InvalidFrontmatterSchema]);
4815    }
4816
4817    #[test]
4818    fn external_references_separate_physical_paths_from_id_based_logical_uris() {
4819        let references = json_schema_external_references(
4820            r#"{
4821                "$id": "https://example.com/schemas/root.json",
4822                "allOf": [
4823                    { "$ref": "defs.json" },
4824                    { "$id": "nested/child.json", "$ref": "more.json" }
4825                ]
4826            }"#,
4827            "file:///workspace/frontmatter.schema.json",
4828            "https://outlint.invalid/workspace/frontmatter.schema.json",
4829        )
4830        .expect("references resolve under both bases");
4831
4832        assert_eq!(
4833            references,
4834            vec![
4835                crate::JsonSchemaExternalReference {
4836                    physical_uri: "file:///workspace/defs.json".into(),
4837                    logical_uri: "https://example.com/schemas/defs.json".into(),
4838                },
4839                crate::JsonSchemaExternalReference {
4840                    physical_uri: "file:///workspace/more.json".into(),
4841                    logical_uri: "https://example.com/schemas/nested/more.json".into(),
4842                },
4843            ]
4844        );
4845    }
4846
4847    #[test]
4848    fn loads_and_resolves_linked_frontmatter_schema_with_local_ref() {
4849        let loaded = linked(
4850            r#"{"$schema":"https://json-schema.org/draft/2020-12/schema","$ref":"defs.json#/$defs/frontmatter"}"#,
4851            &[(
4852                "https://outlint.invalid/defs.json",
4853                r#"{"$defs":{"frontmatter":{"type":"object","required":["status"],"properties":{"status":{"enum":["draft","final"]}}}}}"#,
4854            )],
4855        )
4856        .expect("linked JSON Schema is valid");
4857        let FrontmatterPolicy::Optional { schema: Some(_) } = &loaded.schema.frontmatter else {
4858            panic!("expected an optional linked frontmatter schema")
4859        };
4860        assert!(loaded.sources.documents.contains_key(&SourceId(1)));
4861        assert!(loaded.sources.documents.contains_key(&SourceId(2)));
4862        assert!(loaded
4863            .locations
4864            .nodes
4865            .contains_key(&SchemaNode::FrontmatterSchemaDocument));
4866        let document = crate::parse_markdown(
4867            "---\nstatus: review\n---\n",
4868            crate::MarkdownOptions::default(),
4869        );
4870        let diagnostics =
4871            crate::validate(&loaded.schema, &document).expect("loader-created validator compiles");
4872        assert_eq!(diagnostics.len(), 1);
4873        assert_eq!(diagnostics[0].id, crate::DiagnosticId::FrontmatterSchema);
4874    }
4875
4876    #[test]
4877    fn accepts_circular_fragment_refs_without_validation_time_io() {
4878        linked(
4879            r##"{
4880                "$schema": "https://json-schema.org/draft/2020-12/schema",
4881                "$ref": "#/$defs/node",
4882                "$defs": {
4883                    "node": {
4884                        "type": "object",
4885                        "properties": { "child": { "$ref": "#/$defs/node" } }
4886                    }
4887                }
4888            }"##,
4889            &[],
4890        )
4891        .expect("circular local refs are valid");
4892    }
4893
4894    #[test]
4895    fn accepts_cycles_across_preloaded_resources() {
4896        linked(
4897            r#"{"$ref":"other.json"}"#,
4898            &[(
4899                "https://outlint.invalid/other.json",
4900                r#"{"$ref":"root.json"}"#,
4901            )],
4902        )
4903        .expect("the immutable registry supports cross-resource cycles");
4904    }
4905
4906    #[test]
4907    fn semantic_schema_equality_ignores_resource_labels() {
4908        let first = linked("{\"type\":\"object\"}", &[]).expect("first schema is valid");
4909        let mut input = resource("https://outlint.invalid/root.json", "{\"type\":\"object\"}");
4910        input.label = Some(SourceLabel("a/different/location.json".into()));
4911        let second = load_schema_with_resources(
4912            linked_schema_source(),
4913            Some(SourceLabel("elsewhere/schema.yml".into())),
4914            Some(LinkedJsonSchemaInput {
4915                root_uri: "https://outlint.invalid/root.json".into(),
4916                resources: vec![input],
4917            }),
4918        )
4919        .expect("second schema is valid");
4920        assert_eq!(first.schema, second.schema);
4921    }
4922
4923    #[test]
4924    fn rejects_remote_refs_without_network_retrieval() {
4925        let remote_uri = "https://example.invalid/frontmatter.schema.json";
4926        let invalid = linked(
4927            r#"{
4928                "$schema": "https://json-schema.org/draft/2020-12/schema",
4929                "$ref": "https://example.invalid/frontmatter.schema.json"
4930            }"#,
4931            &[],
4932        )
4933        .expect_err("remote refs are unsupported");
4934        assert_eq!(
4935            invalid.errors.first.kind,
4936            SchemaErrorKind::InvalidFrontmatterSchema
4937        );
4938        assert_eq!(invalid.errors.first.range.source, SourceId(1));
4939        assert!(
4940            invalid.errors.first.message.contains(&format!(
4941                "JSON Schema resource `{remote_uri}` was not preloaded"
4942            )),
4943            "unexpected retrieval diagnostic: {}",
4944            invalid.errors.first.message
4945        );
4946        assert!(!invalid.errors.first.message.contains("Default retriever"));
4947    }
4948
4949    #[test]
4950    fn preserves_ref_siblings_and_boolean_targets() {
4951        let loaded = linked(
4952            r#"{"$ref":"defs.json#/$defs/base","required":["sibling"]}"#,
4953            &[(
4954                "https://outlint.invalid/defs.json",
4955                r#"{"$defs":{"base":{"required":["target"]}}}"#,
4956            )],
4957        )
4958        .expect("ref with duplicate sibling keyword is valid");
4959        let document = crate::parse_markdown(
4960            "---\ntarget: true\n---\n",
4961            crate::MarkdownOptions::default(),
4962        );
4963        let diagnostics =
4964            crate::validate(&loaded.schema, &document).expect("loader-created validator compiles");
4965        assert_eq!(diagnostics.len(), 1, "`$ref` siblings must both apply");
4966
4967        let loaded = linked(
4968            r#"{"$ref":"defs.json#/$defs/base","type":"object"}"#,
4969            &[(
4970                "https://outlint.invalid/defs.json",
4971                r#"{"$defs":{"base":false}}"#,
4972            )],
4973        )
4974        .expect("boolean ref target is valid");
4975        let diagnostics =
4976            crate::validate(&loaded.schema, &document).expect("loader-created validator compiles");
4977        assert_eq!(diagnostics.len(), 1, "false target must remain rejecting");
4978    }
4979
4980    #[test]
4981    fn positions_invalid_linked_json_schema_in_its_own_source() {
4982        let invalid = linked("{ invalid json }", &[]).expect_err("linked JSON Schema is invalid");
4983        assert_eq!(invalid.errors.iter().count(), 1);
4984        assert_eq!(
4985            invalid.errors.first.kind,
4986            SchemaErrorKind::InvalidFrontmatterSchema
4987        );
4988        assert_eq!(invalid.errors.first.range.source, SourceId(1));
4989        let source = invalid
4990            .sources
4991            .documents
4992            .get(&SourceId(1))
4993            .unwrap_or_else(|| panic!("missing linked JSON Schema source"));
4994        assert_eq!(source.label, Some(SourceLabel("root.json".into())));
4995    }
4996
4997    #[test]
4998    fn positions_invalid_transitive_resource_in_its_own_source() {
4999        let invalid = linked(
5000            r#"{"$ref":"defs.json"}"#,
5001            &[("https://outlint.invalid/defs.json", "{ invalid json }")],
5002        )
5003        .expect_err("transitive resource is invalid");
5004        assert_eq!(invalid.errors.first.range.source, SourceId(2));
5005        assert_eq!(
5006            invalid.sources.documents[&SourceId(2)].label,
5007            Some(SourceLabel("defs.json".into()))
5008        );
5009    }
5010
5011    #[test]
5012    fn collects_independent_linked_resource_errors_in_input_order() {
5013        let invalid = linked(
5014            r#"{"allOf":[{"$ref":"first.json"},{"$ref":"second.json"}]}"#,
5015            &[
5016                ("https://outlint.invalid/first.json", "{ invalid json }"),
5017                ("https://outlint.invalid/second.json", "[]"),
5018            ],
5019        )
5020        .expect_err("both invalid linked resources must be reported");
5021        let errors = invalid.errors.iter().collect::<Vec<_>>();
5022
5023        assert_eq!(errors.len(), 2);
5024        assert_eq!(errors[0].kind, SchemaErrorKind::InvalidFrontmatterSchema);
5025        assert_eq!(errors[0].range.source, SourceId(2));
5026        assert!(errors[0]
5027            .message
5028            .starts_with("invalid linked JSON Schema document:"));
5029        assert_eq!(errors[1].kind, SchemaErrorKind::InvalidFrontmatterSchema);
5030        assert_eq!(errors[1].range.source, SourceId(3));
5031        assert_eq!(
5032            errors[1].message,
5033            "frontmatter JSON Schema root must be an object or boolean"
5034        );
5035    }
5036
5037    #[test]
5038    fn duplicate_linked_resource_error_uses_the_duplicate_occurrence_source() {
5039        let uri = "https://outlint.invalid/duplicate.json";
5040        let mut first = resource(uri, "{ invalid json }");
5041        first.label = Some(SourceLabel("first-duplicate.json".into()));
5042        let mut second = resource(uri, "{}");
5043        second.label = Some(SourceLabel("second-duplicate.json".into()));
5044        let invalid = load_schema_with_resources(
5045            linked_schema_source(),
5046            Some(SourceLabel("schema.yml".into())),
5047            Some(LinkedJsonSchemaInput {
5048                root_uri: "https://outlint.invalid/root.json".into(),
5049                resources: vec![
5050                    resource(
5051                        "https://outlint.invalid/root.json",
5052                        r#"{"$ref":"duplicate.json"}"#,
5053                    ),
5054                    first,
5055                    second,
5056                ],
5057            }),
5058        )
5059        .expect_err("invalid and duplicate resources must both be reported");
5060        let errors = invalid.errors.iter().collect::<Vec<_>>();
5061
5062        assert_eq!(errors.len(), 2);
5063        assert_eq!(errors[0].range.source, SourceId(2));
5064        assert_eq!(errors[1].range.source, SourceId(3));
5065        assert!(errors[1]
5066            .message
5067            .starts_with("duplicate JSON Schema resource URI"));
5068        assert_eq!(
5069            invalid.sources.documents[&SourceId(3)].label,
5070            Some(SourceLabel("second-duplicate.json".into()))
5071        );
5072    }
5073
5074    #[test]
5075    fn positions_linked_schema_read_failures_at_the_unreadable_resource() {
5076        let message = "cannot inspect linked JSON Schema 'missing.json': not found";
5077        for (resources, expected_source, expected_label) in [
5078            (
5079                vec![failed_resource(
5080                    "https://outlint.invalid/root.json",
5081                    "missing-root.json",
5082                    message,
5083                )],
5084                SourceId(1),
5085                "missing-root.json",
5086            ),
5087            (
5088                vec![
5089                    resource(
5090                        "https://outlint.invalid/root.json",
5091                        r#"{"$ref":"missing.json"}"#,
5092                    ),
5093                    failed_resource(
5094                        "https://outlint.invalid/missing.json",
5095                        "missing.json",
5096                        message,
5097                    ),
5098                ],
5099                SourceId(2),
5100                "missing.json",
5101            ),
5102        ] {
5103            let invalid = load_schema_with_resources(
5104                linked_schema_source(),
5105                Some(SourceLabel("schema.yml".into())),
5106                Some(LinkedJsonSchemaInput {
5107                    root_uri: "https://outlint.invalid/root.json".into(),
5108                    resources,
5109                }),
5110            )
5111            .expect_err("linked schema read failure is invalid");
5112
5113            assert_eq!(
5114                invalid.errors.first.kind,
5115                SchemaErrorKind::InvalidFrontmatterSchema
5116            );
5117            assert_eq!(invalid.errors.first.message, message);
5118            assert_eq!(invalid.errors.first.range.source, expected_source);
5119            assert_eq!(
5120                invalid.errors.first.range.range,
5121                TextRange {
5122                    start: ByteOffset(0),
5123                    end: ByteOffset(0),
5124                }
5125            );
5126            let source = &invalid.sources.documents[&expected_source];
5127            assert_eq!(source.label, Some(SourceLabel(expected_label.into())));
5128            assert_eq!(&*source.text, "");
5129        }
5130    }
5131
5132    #[test]
5133    fn rejects_missing_and_unsupported_linked_json_schemas() {
5134        let root = resource("https://outlint.invalid/not-root.json", "{}");
5135        let missing = load_schema_with_resources(
5136            linked_schema_source(),
5137            None,
5138            Some(LinkedJsonSchemaInput {
5139                root_uri: "https://outlint.invalid/root.json".into(),
5140                resources: vec![root],
5141            }),
5142        )
5143        .expect_err("missing linked schema is invalid");
5144        assert_eq!(
5145            missing.errors.first.kind,
5146            SchemaErrorKind::InvalidFrontmatterSchema
5147        );
5148        assert_eq!(missing.errors.first.range.source, SourceId(0));
5149
5150        let unsupported = linked(
5151            r#"{"$schema":"http://json-schema.org/draft-07/schema#","type":"object"}"#,
5152            &[],
5153        )
5154        .expect_err("unsupported dialect is invalid");
5155        assert_eq!(
5156            unsupported.errors.first.kind,
5157            SchemaErrorKind::InvalidFrontmatterSchema
5158        );
5159        assert_eq!(unsupported.errors.first.range.source, SourceId(1));
5160    }
5161
5162    fn linked(root: &str, resources: &[(&str, &str)]) -> LoadSchemaResult {
5163        let mut rest = Vec::new();
5164        for (uri, source) in resources {
5165            rest.push(resource(uri, source));
5166        }
5167        load_schema_with_resources(
5168            linked_schema_source(),
5169            Some(SourceLabel("schema.yml".into())),
5170            Some(LinkedJsonSchemaInput {
5171                root_uri: "https://outlint.invalid/root.json".into(),
5172                resources: std::iter::once(resource("https://outlint.invalid/root.json", root))
5173                    .chain(rest)
5174                    .collect(),
5175            }),
5176        )
5177    }
5178
5179    fn inline(root: &str) -> LoadSchemaResult {
5180        let root: Value = serde_json::from_str(root).expect("test inline schema is valid JSON");
5181        load_schema(&format!(
5182            "version: 1\nfrontmatter:\n  schema: {}\ntitle: null\nsections: []\n",
5183            serde_json::to_string(&root).expect("test inline schema serializes")
5184        ))
5185    }
5186
5187    fn resource(uri: &str, source: &str) -> crate::JsonSchemaResourceInput {
5188        crate::JsonSchemaResourceInput {
5189            uri: uri.into(),
5190            label: Some(SourceLabel(
5191                uri.rsplit('/').next().unwrap_or(uri).to_owned(),
5192            )),
5193            contents: crate::JsonSchemaResourceContents::Loaded(Arc::from(source)),
5194        }
5195    }
5196
5197    fn failed_resource(uri: &str, label: &str, message: &str) -> crate::JsonSchemaResourceInput {
5198        crate::JsonSchemaResourceInput {
5199            uri: uri.into(),
5200            label: Some(SourceLabel(label.into())),
5201            contents: crate::JsonSchemaResourceContents::ReadFailure(message.into()),
5202        }
5203    }
5204
5205    fn linked_schema_source() -> &'static str {
5206        "version: 1\nfrontmatter:\n  schema: root.json\ntitle: null\nsections: []\n"
5207    }
5208
5209    /// Builds a document whose root reference starts a chain of `links` hops,
5210    /// the last of which targets `tail`. It declares `links + 1` references
5211    /// and nests three levels however long the chain is.
5212    fn reference_chain(links: usize, tail: &str) -> String {
5213        let mut definitions = serde_json::Map::new();
5214        definitions.insert("end".into(), serde_json::Value::Bool(true));
5215        for index in 0..links {
5216            let target = if index + 1 == links {
5217                tail.to_owned()
5218            } else {
5219                format!("#/$defs/{}", index + 1)
5220            };
5221            definitions.insert(index.to_string(), serde_json::json!({ "$ref": target }));
5222        }
5223        serde_json::json!({ "$ref": "#/$defs/0", "$defs": definitions }).to_string()
5224    }
5225
5226    /// Builds a fragment chain whose schemas are hidden inside instance data.
5227    ///
5228    /// The root pointer activates the first object in `const`; each object then
5229    /// points at the next array element until the final `true` schema. The
5230    /// result declares exactly `references` `$ref` members even though only
5231    /// the root occupies a keyword position recognized as a subresource.
5232    fn hidden_reference_chain(references: usize) -> Value {
5233        assert!(references > 0, "a reference chain has a root reference");
5234        let mut hidden = (0..references - 1)
5235            .map(|index| serde_json::json!({ "$ref": format!("#/const/{}", index + 1) }))
5236            .collect::<Vec<_>>();
5237        hidden.push(Value::Bool(true));
5238        serde_json::json!({ "$ref": "#/const/0", "const": hidden })
5239    }
5240
5241    #[test]
5242    fn refuses_a_reference_chain_longer_than_the_compiler_can_recurse_over() {
5243        // Compiling a reference re-enters the compiler at its target, so a
5244        // chain costs a stack frame per link while every link of it sits at
5245        // the same JSON depth: the YAML depth limit and `serde_json`'s parse
5246        // limit are both satisfied with room to spare by a chain long enough
5247        // to abort the process. The count is charged before the graph reaches
5248        // the compiler, so the boundary is pinned on both sides -- a graph
5249        // spending the whole budget must still load, or the bound would be
5250        // free to drift downwards unnoticed.
5251        let at_budget = reference_chain(MAX_JSON_SCHEMA_REFERENCES - 1, "#/$defs/end");
5252        assert_eq!(
5253            json_schema_reference_count(
5254                &serde_json::from_str(&at_budget).expect("chain is valid JSON")
5255            ),
5256            MAX_JSON_SCHEMA_REFERENCES
5257        );
5258        linked(&at_budget, &[]).expect("a graph spending the whole budget still loads");
5259
5260        let over_budget = reference_chain(MAX_JSON_SCHEMA_REFERENCES, "#/$defs/end");
5261        let invalid = linked(&over_budget, &[]).expect_err("one reference more is refused");
5262        assert_eq!(
5263            invalid.errors.first.kind,
5264            SchemaErrorKind::InvalidFrontmatterSchema
5265        );
5266        assert_eq!(
5267            invalid.errors.first.message,
5268            json_schema_reference_budget_message()
5269        );
5270        assert_eq!(invalid.errors.first.range.source, SourceId(1));
5271        assert!(invalid.errors.rest.is_empty());
5272    }
5273
5274    #[test]
5275    fn the_reference_budget_counts_dynamic_references_too() {
5276        // `$dynamicRef` compiles through the same function as `$ref` and
5277        // re-enters the compiler the same way, so a chain of them aborts at
5278        // the same length. Counting only `$ref` would leave the crash
5279        // reachable by renaming one keyword.
5280        let over_budget = reference_chain(MAX_JSON_SCHEMA_REFERENCES, "#/$defs/end")
5281            .replace(r#""$ref""#, r#""$dynamicRef""#);
5282        let invalid = linked(&over_budget, &[]).expect_err("a dynamic chain is a chain");
5283        assert_eq!(
5284            invalid.errors.first.message,
5285            json_schema_reference_budget_message()
5286        );
5287    }
5288
5289    #[test]
5290    fn the_reference_budget_spans_the_graph_and_names_where_it_runs_out() {
5291        // The compiler recurses across resource boundaries as readily as
5292        // within one, so a per-document budget would be no budget at all: two
5293        // documents each under it can name a chain twice as long as either.
5294        // The total is therefore charged over the graph, and reported against
5295        // the resource whose references spend the last of it rather than the
5296        // root, so the diagnostic points at a document the author can shorten.
5297        let half = MAX_JSON_SCHEMA_REFERENCES / 2;
5298        let root = reference_chain(half - 1, "defs.json#/$defs/0");
5299        let definitions = reference_chain(half, "#/$defs/end");
5300        assert_eq!(
5301            json_schema_reference_count(&serde_json::from_str(&root).expect("root is valid JSON"))
5302                + json_schema_reference_count(
5303                    &serde_json::from_str(&definitions).expect("defs are valid JSON")
5304                ),
5305            MAX_JSON_SCHEMA_REFERENCES + 1
5306        );
5307
5308        let invalid = linked(
5309            &root,
5310            &[("https://outlint.invalid/defs.json", &definitions)],
5311        )
5312        .expect_err("a chain split across two documents is still one chain");
5313        assert_eq!(
5314            invalid.errors.first.kind,
5315            SchemaErrorKind::InvalidFrontmatterSchema
5316        );
5317        assert_eq!(
5318            invalid.errors.first.message,
5319            json_schema_reference_budget_message()
5320        );
5321        assert_eq!(invalid.errors.first.range.source, SourceId(2));
5322    }
5323
5324    #[test]
5325    fn rejects_implication_objects_with_the_wrong_keys() {
5326        let kinds = error_kinds(
5327            r#"
5328version: 1
5329sections: []
5330constraints:
5331  - requires: { condition: foo, consequence: bar }
5332"#,
5333        );
5334        assert!(kinds.contains(&SchemaErrorKind::InvalidDocumentShape));
5335    }
5336
5337    proptest! {
5338        #[test]
5339        fn regex_body_round_trips_delimiter_escaping_without_other_escapes(
5340            source in any::<String>(),
5341        ) {
5342            prop_assume!(!source.contains('\\'));
5343            let encoded = source
5344                .chars()
5345                .flat_map(|character| {
5346                    if character == '/' {
5347                        vec!['\\', '/']
5348                    } else {
5349                        vec![character]
5350                    }
5351                })
5352                .collect::<String>();
5353            let decoded = regex_body(&encoded);
5354            prop_assert_eq!(decoded.as_deref(), Some(source.as_str()));
5355        }
5356
5357        #[test]
5358        fn parse_repeat_normalizes_valid_finite_bounds(min in any::<u32>(), max in any::<u32>()) {
5359            prop_assume!(max >= min && max > 0);
5360            let source = format!("{min}..{max}");
5361            prop_assert_eq!(
5362                parse_repeat(&source),
5363                Some(Cardinality {
5364                    min,
5365                    max: UpperBound::Bounded(max),
5366                })
5367            );
5368        }
5369
5370        #[test]
5371        fn parse_repeat_normalizes_unbounded_bounds(min in any::<u32>()) {
5372            let source = format!("{min}..n");
5373            prop_assert_eq!(
5374                parse_repeat(&source),
5375                Some(Cardinality {
5376                    min,
5377                    max: UpperBound::Unbounded,
5378                })
5379            );
5380        }
5381
5382        #[test]
5383        fn canonical_integer_normalization_is_idempotent(value in any::<i64>()) {
5384            let source = if value >= 0 {
5385                format!("+000{value}")
5386            } else {
5387                format!("-000{}", value.unsigned_abs())
5388            };
5389            let canonical = canonical_integer(&source).expect("generated decimal is valid");
5390            prop_assert_eq!(canonical.as_str(), value.to_string());
5391            let repeated = canonical_integer(&canonical);
5392            prop_assert_eq!(repeated.as_deref(), Some(canonical.as_str()));
5393        }
5394
5395        #[test]
5396        fn canonical_float_normalization_is_idempotent(
5397            coefficient in any::<i64>(),
5398            exponent in any::<i16>(),
5399        ) {
5400            let source = format!("{coefficient}e{exponent}");
5401            let canonical = canonical_float(&source).expect("generated decimal float is valid");
5402            let repeated = canonical_float(&canonical);
5403            prop_assert_eq!(repeated.as_deref(), Some(canonical.as_str()));
5404        }
5405    }
5406
5407    #[test]
5408    fn ordered_resolves_from_the_rule_or_else_the_option() {
5409        let schema = valid(
5410            "version: 1\nsections:\n  - match: A\n  - match: B\n    ordered: false\n    sections:\n      - match: C\n        ordered: true\n",
5411        );
5412        assert!(schema.options.ordered_sections);
5413        let title = &schema.outline[0];
5414        assert!(title.ordered);
5415        assert!(title.sections[0].ordered);
5416        assert!(!title.sections[1].ordered);
5417        assert!(title.sections[1].sections[0].ordered);
5418
5419        let opted_out = valid(
5420            "version: 1\noptions:\n  ordered_sections: false\noutline:\n  - match: A\n  - match: B\n    ordered: true\n",
5421        );
5422        assert!(!opted_out.options.ordered_sections);
5423        assert!(!opted_out.outline[0].ordered);
5424        assert!(opted_out.outline[1].ordered);
5425    }
5426
5427    #[test]
5428    fn ordered_must_be_a_bool_and_the_option_must_be_known() {
5429        let invalid = invalid("version: 1\nsections:\n  - match: A\n    ordered: yes please\n");
5430        assert!(invalid
5431            .errors
5432            .iter()
5433            .any(|error| error.kind == SchemaErrorKind::InvalidDocumentShape
5434                && error.message == "rule `ordered` must be a bool and cannot be null"));
5435        let invalid =
5436            self::invalid("version: 1\noptions:\n  ordered: false\nsections:\n  - match: A\n");
5437        assert!(invalid
5438            .errors
5439            .iter()
5440            .any(|error| error.kind == SchemaErrorKind::InvalidDocumentShape
5441                && error.message == "unknown field `ordered`"));
5442    }
5443
5444    #[test]
5445    fn an_explicit_ordered_constraint_over_an_ordered_scope_is_refused() {
5446        // Redundant or contradictory, the fix is the same: the message says
5447        // which knob to turn.
5448        let redundant = "version: 1\nsections:\n  - id: a\n    match: A\n  - id: b\n    match: B\nconstraints:\n  - ordered: [a, b]\n";
5449        let refused = invalid(redundant);
5450        let error = refused
5451            .errors
5452            .iter()
5453            .find(|error| error.kind == SchemaErrorKind::OrderedScopeMismatch)
5454            .expect("the ordered scope refuses the constraint");
5455        assert!(error.message.contains("already ordered by its rule list"));
5456        assert!(error.message.contains("`ordered: false`"));
5457        assert_eq!(
5458            source_slice(redundant, error.range).trim_end(),
5459            "ordered: [a, b]"
5460        );
5461
5462        // The same refs are welcome once the scope is unordered — by the
5463        // option at the root, or by the owning rule one level down, whether
5464        // reached by bare ids or by a path from the root.
5465        valid("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");
5466        valid("version: 1\nsections:\n  - id: s\n    match: S\n    ordered: false\n    sections:\n      - id: a\n        match: A\n      - id: b\n        match: B\n    constraints:\n      - ordered: [b, a]\n");
5467        valid("version: 1\nsections:\n  - id: s\n    match: S\n    required: true\n    ordered: false\n    sections:\n      - id: a\n        match: A\n      - id: b\n        match: B\nconstraints:\n  - ordered: [s.b, s.a]\n");
5468        // A path into an ordered nested scope is refused like a bare ref.
5469        let nested = invalid("version: 1\noptions:\n  ordered_sections: false\nsections:\n  - id: s\n    match: S\n    required: true\n    ordered: true\n    sections:\n      - id: a\n        match: A\n      - id: b\n        match: B\nconstraints:\n  - ordered: [s.a, s.b]\n");
5470        assert!(nested
5471            .errors
5472            .iter()
5473            .any(|error| error.kind == SchemaErrorKind::OrderedScopeMismatch));
5474        // Mixed scopes are already refused; the redundancy check stays quiet.
5475        let mixed = invalid("version: 1\nsections:\n  - id: s\n    match: S\n    required: true\n    sections:\n      - id: a\n        match: A\n  - id: b\n    match: B\nconstraints:\n  - ordered: [s.a, b]\n");
5476        assert_eq!(
5477            mixed
5478                .errors
5479                .iter()
5480                .filter(|error| error.kind == SchemaErrorKind::OrderedScopeMismatch)
5481                .count(),
5482            1
5483        );
5484    }
5485}