outlint_core/schema.rs
1//! The normalized, type-safe representation of an Outlint schema.
2//!
3//! These types intentionally do not mirror the YAML document one-for-one.
4//! Surface syntax such as `required`, dotted references, slash-delimited
5//! regular expressions, `fm.` propositions, and `"n"` repeat bounds is
6//! expected to be normalized by the schema loader before constructing this
7//! model.
8
9use std::collections::BTreeMap;
10
11use serde_json::Value as JsonValue;
12
13/// A parsed Outlint schema.
14///
15/// Obtain this value from [`load_schema`](crate::load_schema) or
16/// [`load_schema_with_resources`](crate::load_schema_with_resources). Although
17/// its normalized fields are public for inspection, constructing them directly
18/// can bypass loader-established invariants such as valid references and
19/// compiled matchers.
20#[derive(Debug, Clone, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct Schema {
23 /// The schema language version used by this document.
24 pub version: SchemaVersion,
25 /// Document parsing and matching options, with defaults already applied.
26 pub options: Options,
27 /// The normalized frontmatter presence and value-validation policy.
28 pub frontmatter: FrontmatterPolicy,
29 /// Rules for the document's `h1` headers, in first-match order.
30 ///
31 /// This is the canonical form of the schema's top level. The
32 /// `title:` + `sections:` sugar desugars into a single synthesized rule
33 /// here — its matcher is the title matcher (or any-text when no title is
34 /// declared), its cardinality is exactly one, and its child rules are the
35 /// top-level `sections` list. [`Schema::outline_provenance`] records which
36 /// spelling produced the list; public [`ScopePath`]s keep addressing what
37 /// the source spelled, so for sugar schemas the empty scope names the
38 /// synthesized rule's child scope rather than this list.
39 ///
40 /// [`ScopePath`]: crate::ScopePath
41 pub outline: Vec<SectionRule>,
42 /// Presence and ordering constraints attached to the outline (`h1`) scope.
43 ///
44 /// Only the general `outline:` form can declare these. A sugar schema's
45 /// top-level constraints attach to the synthesized rule's child scope
46 /// (its `constraints` field) — the scope the `sections` list describes —
47 /// so this list is empty for every sugar schema.
48 pub constraints: Vec<Constraint>,
49 /// How the source document declared its `h1` level.
50 pub outline_provenance: OutlineProvenance,
51}
52
53impl Schema {
54 /// Whether the h1 level was declared through sugar rather than `outline:`.
55 ///
56 /// Sugar schemas keep their pre-`outline` public addressing: the empty
57 /// [`ScopePath`] and the `$.` reference anchor both name the synthesized
58 /// rule's child scope (the `sections` list), and the synthesized `h1` rule
59 /// itself is addressed as [`SchemaNode::Title`] rather than as a rule.
60 ///
61 /// [`ScopePath`]: crate::ScopePath
62 /// [`SchemaNode::Title`]: crate::SchemaNode::Title
63 pub(crate) fn is_sugar(&self) -> bool {
64 !matches!(self.outline_provenance, OutlineProvenance::Outline)
65 }
66
67 /// The rules the empty public [`ScopePath`] (and the `$.` anchor) names.
68 ///
69 /// For the general form this is [`Schema::outline`]; for sugar it is the
70 /// synthesized rule's child scope, which is what the source's `sections`
71 /// list spelled. Rule references and scope walks resolve from here so
72 /// that a sugar schema's references keep meaning what they always meant.
73 ///
74 /// [`ScopePath`]: crate::ScopePath
75 pub(crate) fn addressed_root_rules(&self) -> &[SectionRule] {
76 if self.is_sugar() {
77 self.outline
78 .first()
79 .map_or(&[], |rule| rule.sections.as_slice())
80 } else {
81 &self.outline
82 }
83 }
84}
85
86/// The surface form a schema used to declare its `h1` level.
87///
88/// The loader normalizes every form into [`Schema::outline`], the canonical
89/// `h1`-rule list. The provenance records which spelling produced it: the
90/// validator keeps `missing-title` and the wrong-title diagnostics anchored at
91/// [`SchemaNode::Title`] for the sugar forms, preserves their lax handling of
92/// documents without an `h1`, and gives `outline:` and `title: null` their own
93/// semantics.
94///
95/// [`SchemaNode::Title`]: crate::SchemaNode::Title
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum OutlineProvenance {
98 /// `title: <matcher>` with `sections:` — sugar for a single required
99 /// `h1` rule whose child rules are the top-level `sections` list.
100 Title,
101 /// `sections:` without `title:` — desugars like [`Self::Title`] with an
102 /// any-text matcher: `title: "*"` implied, so the document must have
103 /// exactly one `h1`. A document with none writes `title: null` into its
104 /// schema instead. With no `title:` key to blame, title diagnostics
105 /// anchor on the `sections` key — the spelling that implied the rule.
106 BareSections,
107 /// `title: null` — the document is declared to have no `h1`. Desugars to
108 /// a denied any-text `h1` rule: a present `h1` is `not-allowed`, and the
109 /// `sections` list describes the document's top-level `h2` headers.
110 NoTitle,
111 /// The general `outline:` form: [`Schema::outline`] is exactly what the
112 /// source spelled.
113 Outline,
114}
115
116/// The document's normalized frontmatter policy.
117///
118/// This representation makes the invalid `required: true, allow: false`
119/// combination unrepresentable while retaining a schema declared alongside
120/// `allow: false`; if forbidden frontmatter is nevertheless present, the
121/// validation algorithm still evaluates that schema.
122#[derive(Debug, Clone, PartialEq, Eq)]
123#[non_exhaustive]
124pub enum FrontmatterPolicy {
125 /// Frontmatter may be absent; validate it against `schema` when present.
126 Optional {
127 /// JSON Schema to apply when frontmatter is present.
128 schema: Option<FrontmatterSchema>,
129 },
130 /// Frontmatter must be present and is validated against `schema`.
131 Required {
132 /// JSON Schema to apply to the required frontmatter mapping.
133 schema: Option<FrontmatterSchema>,
134 },
135 /// Frontmatter must not be present; validate it against `schema` if it is
136 /// nevertheless present.
137 Forbidden {
138 /// JSON Schema to apply if forbidden frontmatter is present.
139 schema: Option<FrontmatterSchema>,
140 },
141}
142
143/// An opaque, normalized JSON Schema resource graph.
144///
145/// Construction is restricted to the loader, which checks the dialect,
146/// meta-schema, and every reference before this value enters a [`Schema`].
147/// Resource identifiers are logical URIs rather than filesystem locations, so
148/// moving an otherwise identical schema does not change semantic equality.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct FrontmatterSchema {
151 pub(crate) root_uri: String,
152 pub(crate) root: JsonValue,
153 pub(crate) resources: BTreeMap<String, JsonValue>,
154}
155
156/// A supported version of the Outlint schema language.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
158pub enum SchemaVersion {
159 /// Version 1 of the schema language.
160 V1,
161}
162
163/// Options controlling Markdown parsing and matcher behavior.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165#[non_exhaustive]
166pub struct Options {
167 /// Whether all matcher forms compare text case-sensitively.
168 pub match_case: bool,
169 /// Whether inline Markdown is reduced to its text before matching.
170 pub strip_inline_markup: bool,
171 /// Whether a header may be more than one level below its parent.
172 pub allow_skipped_levels: bool,
173 /// Whether a scope's rules bind in document order unless a rule's own
174 /// `ordered` says otherwise (specification §3.7).
175 pub ordered_sections: bool,
176}
177
178impl Options {
179 /// Sets case sensitivity for every matcher form.
180 pub const fn with_match_case(mut self, match_case: bool) -> Self {
181 self.match_case = match_case;
182 self
183 }
184
185 /// Sets whether inline Markdown is reduced to visible text for matching.
186 pub const fn with_strip_inline_markup(mut self, strip_inline_markup: bool) -> Self {
187 self.strip_inline_markup = strip_inline_markup;
188 self
189 }
190
191 /// Sets whether headings may skip a level in the document tree.
192 pub const fn with_allow_skipped_levels(mut self, allow_skipped_levels: bool) -> Self {
193 self.allow_skipped_levels = allow_skipped_levels;
194 self
195 }
196
197 /// Sets the default for whether each scope's rules bind in document order.
198 pub const fn with_ordered_sections(mut self, ordered_sections: bool) -> Self {
199 self.ordered_sections = ordered_sections;
200 self
201 }
202}
203
204impl Default for Options {
205 /// Uses the defaults defined by specification §7.
206 fn default() -> Self {
207 Self {
208 match_case: false,
209 strip_inline_markup: true,
210 allow_skipped_levels: false,
211 ordered_sections: true,
212 }
213 }
214}
215
216/// A Markdown ATX header level.
217///
218/// Using an enum keeps values outside Markdown's `h1` through `h6` range out
219/// of an already-parsed schema.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
221#[repr(u8)]
222pub enum HeaderLevel {
223 /// A level-one heading (`#`).
224 H1 = 1,
225 /// A level-two heading (`##`).
226 H2 = 2,
227 /// A level-three heading (`###`).
228 H3 = 3,
229 /// A level-four heading (`####`).
230 H4 = 4,
231 /// A level-five heading (`#####`).
232 H5 = 5,
233 /// A level-six heading (`######`).
234 H6 = 6,
235}
236
237impl TryFrom<u8> for HeaderLevel {
238 type Error = ();
239
240 /// Converts only Markdown's representable h1 through h6 levels.
241 fn try_from(value: u8) -> Result<Self, Self::Error> {
242 match value {
243 1 => Ok(Self::H1),
244 2 => Ok(Self::H2),
245 3 => Ok(Self::H3),
246 4 => Ok(Self::H4),
247 5 => Ok(Self::H5),
248 6 => Ok(Self::H6),
249 _ => Err(()),
250 }
251 }
252}
253
254/// A rule for headers within one scope.
255#[derive(Debug, Clone, PartialEq, Eq)]
256#[non_exhaustive]
257pub struct SectionRule {
258 /// The explicit or generated identifier used by constraints.
259 ///
260 /// Non-exact matchers without an explicit identifier remain `None`.
261 pub id: Option<RuleId>,
262 /// The header matcher for this rule.
263 pub matcher: Matcher,
264 /// Whether matching headers are accepted and, if so, their cardinality.
265 pub outcome: RuleOutcome,
266 /// Whether headers unmatched by a child rule are rejected.
267 pub strict: bool,
268 /// Whether the child rules bind in document order: every header matched
269 /// by an earlier accepting rule must precede every header matched by a
270 /// later one (specification §3.7). Resolved from the rule's own `ordered`
271 /// key or, absent that, [`Options::ordered_sections`].
272 pub ordered: bool,
273 /// Rules for direct child headers, in first-match order.
274 pub sections: Vec<SectionRule>,
275 /// Presence and ordering constraints attached to the child scope.
276 pub constraints: Vec<Constraint>,
277}
278
279/// The result of matching a header against a section rule.
280///
281/// A denied rule has no cardinality, making the invalid combination of
282/// `allow: false` and `required`/`repeat` unrepresentable here.
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum RuleOutcome {
285 /// Matching headings are accepted subject to the carried cardinality.
286 Allow(Cardinality),
287 /// Matching headings are rejected, so no cardinality applies.
288 Deny,
289}
290
291/// The permitted number of sibling headers matched by one rule.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
293pub struct Cardinality {
294 /// Inclusive minimum number of matching sibling headings.
295 pub min: u32,
296 /// Inclusive maximum number of matching sibling headings.
297 pub max: UpperBound,
298}
299
300/// The inclusive upper bound of a rule's cardinality.
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
302pub enum UpperBound {
303 /// At most the carried number of headings may match.
304 Bounded(u32),
305 /// Any number of headings may match.
306 Unbounded,
307}
308
309/// A normalized header matcher.
310#[derive(Debug, Clone, PartialEq, Eq)]
311#[non_exhaustive]
312pub enum Matcher {
313 /// Literal header text equality.
314 Exact(ExactText),
315 /// A pattern in which `*` matches any substring.
316 Glob(GlobPattern),
317 /// A full-string regular expression, without the delimiting slashes.
318 Regex(RegexPattern),
319 /// Any header text; the normalized form of `match: "*"`.
320 Any,
321}
322
323/// Literal text used by an exact matcher.
324#[derive(Debug, Clone, PartialEq, Eq, Hash)]
325#[repr(transparent)]
326pub struct ExactText(pub String);
327
328/// The validated body of a glob matcher.
329///
330/// Construction is restricted to the schema loader so callers cannot bypass
331/// normalization and validation. Use [`Self::as_str`] to inspect the value.
332#[derive(Debug, Clone, PartialEq, Eq, Hash)]
333#[repr(transparent)]
334pub struct GlobPattern(pub(crate) String);
335
336impl GlobPattern {
337 /// Returns the normalized pattern body.
338 pub fn as_str(&self) -> &str {
339 &self.0
340 }
341}
342
343/// The validated body of a regular-expression matcher, without `/` delimiters.
344///
345/// Construction is restricted to the schema loader so callers cannot create a
346/// semantic schema containing an invalid regular expression.
347#[derive(Debug, Clone, PartialEq, Eq, Hash)]
348#[repr(transparent)]
349pub struct RegexPattern(pub(crate) String);
350
351impl RegexPattern {
352 /// Returns the normalized pattern body.
353 pub fn as_str(&self) -> &str {
354 &self.0
355 }
356}
357
358/// A validated rule identifier.
359///
360/// Construction is restricted to the schema loader, which enforces the
361/// identifier grammar and reserved-name rules.
362#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
363#[repr(transparent)]
364pub struct RuleId(pub(crate) String);
365
366impl RuleId {
367 /// Returns the normalized identifier.
368 pub fn as_str(&self) -> &str {
369 &self.0
370 }
371}
372
373/// A cross-section presence or ordering constraint.
374#[derive(Debug, Clone, PartialEq, Eq)]
375#[non_exhaustive]
376pub enum Constraint {
377 /// Exactly one proposition must be satisfied.
378 OneOf(AtLeastTwo<Proposition>),
379 /// At least one proposition must be satisfied.
380 AnyOf(AtLeastTwo<Proposition>),
381 /// Zero or one proposition may be satisfied.
382 AtMostOne(AtLeastTwo<Proposition>),
383 /// Either all propositions or none of them must be satisfied.
384 AllOrNone(AtLeastTwo<Proposition>),
385 /// If `condition` is satisfied, every `consequence` must be satisfied.
386 Requires {
387 /// Proposition that activates the requirement.
388 condition: Proposition,
389 /// Propositions required whenever the condition is satisfied.
390 consequences: NonEmpty<Proposition>,
391 },
392 /// If `condition` is satisfied, every `exclusion` must be unsatisfied.
393 Conflicts {
394 /// Proposition that activates the conflict.
395 condition: Proposition,
396 /// Propositions forbidden whenever the condition is satisfied.
397 exclusions: NonEmpty<Proposition>,
398 },
399 /// Every occurrence of each satisfied ref must precede every occurrence of
400 /// the next satisfied ref (`last(A) < first(B)`).
401 ///
402 /// Frontmatter propositions are excluded because they have no document
403 /// position among headers.
404 Ordered(AtLeastTwo<RuleRef>),
405}
406
407/// A proposition accepted by presence constraints.
408#[derive(Debug, Clone, PartialEq, Eq, Hash)]
409pub enum Proposition {
410 /// Presence of a concrete rule path in the section tree.
411 Rule(RuleRef),
412 /// Presence or typed equality of a value in document frontmatter.
413 Frontmatter(FrontmatterRef),
414}
415
416/// A normalized `fm.` frontmatter proposition.
417#[derive(Debug, Clone, PartialEq, Eq, Hash)]
418pub struct FrontmatterRef {
419 /// One or more mapping keys below the frontmatter root.
420 pub path: NonEmpty<FrontmatterKey>,
421 /// A typed scalar for the equality form, or `None` for presence alone.
422 pub equals: Option<FrontmatterScalar>,
423}
424
425/// A frontmatter mapping key addressable by the `fm.` syntax.
426///
427/// The loader ensures this is non-empty and contains neither `.` nor `=`.
428#[derive(Debug, Clone, PartialEq, Eq, Hash)]
429#[repr(transparent)]
430pub struct FrontmatterKey(pub(crate) String);
431
432impl FrontmatterKey {
433 /// Returns the mapping key as it appeared in the normalized reference.
434 pub fn as_str(&self) -> &str {
435 &self.0
436 }
437}
438
439/// A scalar resolved according to the YAML 1.2 core schema.
440///
441/// Integer and float values retain arbitrary precision as canonical strings.
442/// Their distinct variants preserve the spec's typed equality (`1` is not
443/// equal to `1.0`) without forcing a numeric precision limit on schema input.
444#[derive(Debug, Clone, PartialEq, Eq, Hash)]
445pub enum FrontmatterScalar {
446 /// YAML's null value.
447 Null,
448 /// A YAML boolean.
449 Boolean(bool),
450 /// A YAML integer in canonical arbitrary-precision form.
451 Integer(CanonicalInteger),
452 /// A YAML floating-point value in canonical arbitrary-precision form.
453 Float(CanonicalFloat),
454 /// A YAML string.
455 String(String),
456}
457
458/// The canonical, arbitrary-precision value of a YAML integer scalar.
459///
460/// Construction is restricted to the schema loader, which validates and
461/// canonicalizes the source spelling.
462#[derive(Debug, Clone, PartialEq, Eq, Hash)]
463#[repr(transparent)]
464pub struct CanonicalInteger(pub(crate) String);
465
466impl CanonicalInteger {
467 /// Returns the canonical decimal spelling.
468 pub fn as_str(&self) -> &str {
469 &self.0
470 }
471}
472
473/// The canonical, arbitrary-precision value of a YAML float scalar.
474///
475/// Construction is restricted to the schema loader, which validates and
476/// canonicalizes the source spelling.
477#[derive(Debug, Clone, PartialEq, Eq, Hash)]
478#[repr(transparent)]
479pub struct CanonicalFloat(pub(crate) String);
480
481impl CanonicalFloat {
482 /// Returns the canonical decimal spelling.
483 pub fn as_str(&self) -> &str {
484 &self.0
485 }
486}
487
488/// A normalized reference to a rule path.
489#[derive(Debug, Clone, PartialEq, Eq, Hash)]
490pub struct RuleRef {
491 /// The scope from which the first path segment is resolved.
492 pub anchor: RefAnchor,
493 /// One or more rule identifiers forming the path to the target.
494 pub path: NonEmpty<RuleId>,
495}
496
497/// The starting scope for resolving a rule reference.
498#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
499pub enum RefAnchor {
500 /// Resolve from the direct-child scope where the constraint is attached.
501 CurrentScope,
502 /// Resolve from the schema's root scope; the normalized form of a leading
503 /// `$.` in source. `$` alone is not a valid reference.
504 SchemaRoot,
505}
506
507/// A collection statically guaranteed to contain at least one item.
508#[derive(Debug, Clone, PartialEq, Eq, Hash)]
509pub struct NonEmpty<T> {
510 /// The item whose presence establishes the non-empty invariant.
511 pub first: T,
512 /// Remaining items in collection order.
513 pub rest: Vec<T>,
514}
515
516impl<T> NonEmpty<T> {
517 /// Iterates every item in collection order.
518 pub fn iter(&self) -> impl Iterator<Item = &T> {
519 std::iter::once(&self.first).chain(&self.rest)
520 }
521}
522
523/// A collection statically guaranteed to contain at least two items.
524#[derive(Debug, Clone, PartialEq, Eq, Hash)]
525pub struct AtLeastTwo<T> {
526 /// First item in collection order.
527 pub first: T,
528 /// Second item, whose presence establishes the at-least-two invariant.
529 pub second: T,
530 /// Remaining items in collection order.
531 pub rest: Vec<T>,
532}
533
534impl<T> AtLeastTwo<T> {
535 /// Iterates every item in collection order.
536 pub fn iter(&self) -> impl Iterator<Item = &T> {
537 std::iter::once(&self.first)
538 .chain(std::iter::once(&self.second))
539 .chain(&self.rest)
540 }
541}