Skip to main content

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}