1#![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
38type JsonMap = serde_json::Map<String, Value>;
40
41pub fn load_schema(source: &str) -> LoadSchemaResult {
67 load_schema_with_label(source, None)
68}
69
70pub fn load_schema_with_label(source: &str, label: Option<SourceLabel>) -> LoadSchemaResult {
72 Loader::new(Arc::from(source), label, None).load()
73}
74
75pub 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
99pub 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
114pub 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#[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
194pub(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 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 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(®istry)
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 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
336const 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 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(®istry)
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
438pub(crate) const MAX_JSON_SCHEMA_REFERENCES: usize = 128;
466
467pub(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
475pub(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 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 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 OutlineRule(RuleIndex),
651 OutlineRuleField(RuleIndex, String),
653}
654
655#[derive(Default)]
656struct RangeIndex {
657 ranges: BTreeMap<RangeKey, SourceRange>,
658}
659
660impl RangeIndex {
661 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 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#[derive(Clone, Debug)]
852struct SchemaYamlNode {
853 kind: SchemaYamlKind,
854 start: usize,
860 end: usize,
861 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
876impl 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
916fn 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
931fn 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
940fn 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
950fn 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#[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#[derive(Debug)]
1013struct AnchoredSchemaYamlNode {
1014 node: SchemaYamlNode,
1015 nodes: usize,
1016 depth: usize,
1017}
1018
1019#[derive(Debug)]
1023struct SchemaYamlSubtree {
1024 node: SchemaYamlNode,
1025 depth: usize,
1026}
1027
1028struct SchemaYamlReader<'source> {
1040 parser: YamlParser<'source, StrInput<'source>>,
1041 anchors: BTreeMap<usize, AnchoredSchemaYamlNode>,
1042 budget: ExactYamlBudget,
1043 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 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 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 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 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 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 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 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 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 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
1310fn 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
1317fn 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
1329fn 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 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
1384fn 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
1443fn 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 char_offsets: Vec<usize>,
1467 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 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 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 let frontmatter_declared = value
1587 .as_object()
1588 .is_some_and(|mapping| mapping.contains_key("frontmatter"));
1589 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 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 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 let outline = sections.map(|sections| {
1676 vec![SectionRule {
1677 id: None,
1678 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 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 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 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 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 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 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 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 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 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 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
2977fn 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
3004fn 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 } 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
3271fn 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 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 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 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 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 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 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 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 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 assert_eq!(source_slice(&source, refused.errors.first.range), "*x125");
3582 assert_eq!(linked_frontmatter_schema_path(&source), None);
3585 }
3586 }
3587
3588 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}