Skip to main content

outlint_core/
load_result.rs

1//! Types returned when loading an Outlint schema from source text.
2//!
3//! Source provenance lives in this layer rather than in the semantic
4//! [`Schema`]. A successfully loaded schema can therefore be compared and used
5//! independently of its original formatting while diagnostics can still point
6//! back to the declarations that produced it. Provenance is multi-source so a
7//! load error in an external frontmatter JSON Schema can name that file rather
8//! than being incorrectly anchored to its path in the primary document.
9
10use std::{collections::BTreeMap, error::Error, fmt, sync::Arc};
11
12use crate::schema::{NonEmpty, Schema};
13
14/// The result of parsing, validating, and normalizing a schema document.
15pub type LoadSchemaResult = Result<LoadedSchema, InvalidSchema>;
16
17/// One attempted JSON Schema resource supplied to the pure schema loader.
18///
19/// The outer I/O shell assigns a stable logical `uri` for reference
20/// resolution. The display `label` and contents remain provenance only.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct JsonSchemaResourceInput {
23    /// Logical absolute URI used by JSON Schema reference resolution.
24    pub uri: String,
25    /// Human-readable filesystem path or caller label.
26    pub label: Option<SourceLabel>,
27    /// Either the complete UTF-8 document or the shell's read failure.
28    pub contents: JsonSchemaResourceContents,
29}
30
31/// Contents of one attempted linked JSON Schema resource read.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum JsonSchemaResourceContents {
34    /// Complete UTF-8 JSON document.
35    Loaded(Arc<str>),
36    /// Exact filesystem or UTF-8 error produced by the I/O shell.
37    ReadFailure(String),
38}
39
40/// Complete immutable input graph for one linked frontmatter JSON Schema.
41///
42/// Every attempted local resource reachable from `root_uri` must be present,
43/// including failed reads. Missing, unreadable, or remote resources are
44/// reported as `invalid-frontmatter-schema` rather than being retrieved by
45/// core.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct LinkedJsonSchemaInput {
48    /// URI of the resource named by `frontmatter.schema`.
49    pub root_uri: String,
50    /// Root and transitive resource documents, keyed by each entry's `uri`.
51    pub resources: Vec<JsonSchemaResourceInput>,
52}
53
54/// One external JSON Schema document reference resolved for both I/O and validation.
55///
56/// Keeping the two identities in one value prevents a shell from accidentally
57/// using a `$id`-rebased URI as the location of a file to preload.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct JsonSchemaExternalReference {
60    /// Target resolved from the resource's lexical file URI without applying `$id`.
61    pub physical_uri: String,
62    /// Target resolved according to JSON Schema base-URI and `$id` semantics.
63    pub logical_uri: String,
64}
65
66/// A valid semantic schema together with its source provenance.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct LoadedSchema {
69    /// The final normalized schema.
70    pub schema: Schema,
71    /// The primary document and any external JSON Schema sources it loaded.
72    pub sources: SchemaSources,
73    /// Locations of semantic nodes retained for later diagnostics.
74    pub locations: SchemaLocations,
75}
76
77/// A schema document that could not be converted into a valid [`Schema`].
78///
79/// No partial semantic schema is exposed on failure.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct InvalidSchema {
82    /// The primary document and any external JSON Schema source attempts made
83    /// before loading failed.
84    pub sources: SchemaSources,
85    /// One or more syntax, shape, or schema-validation errors.
86    pub errors: NonEmpty<SchemaError>,
87}
88
89impl fmt::Display for InvalidSchema {
90    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
91        let additional = self.errors.rest.len();
92        write!(formatter, "{}", self.errors.first)?;
93        if additional > 0 {
94            write!(formatter, " (and {additional} more schema error")?;
95            if additional != 1 {
96                formatter.write_str("s")?;
97            }
98            formatter.write_str(")")?;
99        }
100        Ok(())
101    }
102}
103
104impl Error for InvalidSchema {}
105
106/// The available source text and optional display name of a schema document.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct SchemaSource {
109    /// A path, URI, or caller-provided label used when presenting diagnostics.
110    pub label: Option<SourceLabel>,
111    /// The complete original text, or empty when reading this source failed.
112    pub text: Arc<str>,
113}
114
115/// All source documents participating in one schema load.
116///
117/// [`SourceId`] values are local to this collection. Keeping source identity
118/// in the parser result, instead of in semantic schema nodes, preserves
119/// position-independent equality for [`Schema`].
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct SchemaSources {
122    /// The Outlint schema document requested by the caller.
123    pub primary: SourceId,
124    /// Source text keyed by the ids used in locations and errors.
125    pub documents: BTreeMap<SourceId, SchemaSource>,
126}
127
128/// The identity of one source within [`SchemaSources`].
129#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
130#[repr(transparent)]
131pub struct SourceId(pub u32);
132
133/// A human-readable name for a schema source.
134#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
135#[repr(transparent)]
136pub struct SourceLabel(pub String);
137
138/// Side-car locations for nodes in a successfully built [`Schema`].
139///
140/// Node addresses follow the structure of the normalized schema rather than
141/// using rule ids, which are optional and only locally unique.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct SchemaLocations {
144    /// The range covering the complete primary Outlint schema document.
145    pub document: SourceRange,
146    /// Source ranges for semantic nodes needed by validation diagnostics.
147    pub nodes: BTreeMap<SchemaNode, SourceRange>,
148}
149
150/// The address of a semantic schema node with retained source provenance.
151#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
152pub enum SchemaNode {
153    /// The optional title matcher.
154    Title,
155    /// The normalized frontmatter policy object.
156    Frontmatter,
157    /// The policy's `schema` value in the primary Outlint schema source.
158    ///
159    /// This is either the linked schema path or the complete inline mapping.
160    FrontmatterSchemaDeclaration,
161    /// The parsed JSON Schema root document.
162    ///
163    /// For a linked schema this points into the external source while
164    /// [`Self::FrontmatterSchemaDeclaration`] remains in the primary source.
165    /// For an inline schema both nodes cover its mapping in the primary source.
166    FrontmatterSchemaDocument,
167    /// A section rule at a structural path.
168    Rule(RulePath),
169    /// A constraint at a structural path.
170    Constraint(ConstraintPath),
171}
172
173/// The structural address of a section rule.
174#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
175pub struct RulePath {
176    /// The scope containing the rule.
177    pub scope: ScopePath,
178    /// The rule's zero-based index within that scope.
179    pub index: RuleIndex,
180}
181
182/// The structural address of a constraint.
183#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
184pub struct ConstraintPath {
185    /// The scope containing the constraint.
186    pub scope: ScopePath,
187    /// The constraint's zero-based index within that scope.
188    pub index: ConstraintIndex,
189}
190
191/// A path to a rule-owned child scope.
192///
193/// Each index selects a rule whose child scope contains the next index. For an
194/// `outline:` schema, the empty path denotes [`Schema::outline`]. For a
195/// `title:` + `sections:` sugar schema, it denotes the synthesized title
196/// rule's child scope — the source's top-level `sections:` list. This preserves
197/// the public addressing of the source form after normalization.
198#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
199#[repr(transparent)]
200pub struct ScopePath(pub Vec<RuleIndex>);
201
202/// A zero-based rule index within one sibling rule list.
203#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
204#[repr(transparent)]
205pub struct RuleIndex(pub usize);
206
207/// A zero-based constraint index within one scope's constraint list.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
209#[repr(transparent)]
210pub struct ConstraintIndex(pub usize);
211
212/// A half-open byte range in [`SchemaSource::text`].
213#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
214pub struct TextRange {
215    /// Inclusive byte offset at which the range begins.
216    pub start: ByteOffset,
217    /// Exclusive byte offset at which the range ends.
218    pub end: ByteOffset,
219}
220
221/// A byte offset in UTF-8 source text.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
223#[repr(transparent)]
224pub struct ByteOffset(pub usize);
225
226/// A byte range associated with one source in [`SchemaSources`].
227#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
228pub struct SourceRange {
229    /// Document containing the range.
230    pub source: SourceId,
231    /// Half-open byte range within that document's source text.
232    pub range: TextRange,
233}
234
235/// A positioned error produced while loading a schema.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct SchemaError {
238    /// A machine-readable error category.
239    pub kind: SchemaErrorKind,
240    /// The primary source range associated with the error.
241    pub range: SourceRange,
242    /// Additional declarations or values relevant to the error.
243    pub related: Vec<RelatedLocation>,
244    /// A human-readable explanation.
245    pub message: String,
246}
247
248impl fmt::Display for SchemaError {
249    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
250        write!(formatter, "{}: {}", self.kind, self.message)
251    }
252}
253
254impl Error for SchemaError {}
255
256/// A secondary source range attached to a schema error.
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct RelatedLocation {
259    /// Secondary source range relevant to the error.
260    pub range: SourceRange,
261    /// Explanation of how the secondary location relates to the error.
262    pub message: String,
263}
264
265/// Machine-readable categories for schema loading failures.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
267#[non_exhaustive]
268pub enum SchemaErrorKind {
269    /// The input is not syntactically valid YAML or JSON.
270    Syntax,
271    /// The parsed value does not have the required schema document shape.
272    InvalidDocumentShape,
273    /// The declared schema version is not supported.
274    UnsupportedVersion,
275    /// Two rules in one sibling scope resolve to the same id.
276    DuplicateId,
277    /// A constraint reference does not resolve to a rule.
278    UnresolvedRef,
279    /// A constraint references a rule that denies matching sections.
280    ForbiddenRef,
281    /// A constraint contains the same resolved proposition more than once.
282    DuplicateRef,
283    /// A top-level rule uses the reserved `fm` identifier.
284    ReservedId,
285    /// A matcher cannot be normalized or compiled.
286    InvalidMatcher,
287    /// A repeat declaration is malformed or has inconsistent bounds.
288    InvalidRepeat,
289    /// An ordered constraint uses a non-positional frontmatter proposition,
290    /// mixes scopes, descends through a repeatable ancestor, or targets a
291    /// scope already ordered by its rule list.
292    OrderedScopeMismatch,
293    /// A rule declares both `required` and `repeat`, or denies a cardinality.
294    ConflictingCardinality,
295    /// The frontmatter policy both requires and forbids frontmatter.
296    ConflictingFrontmatter,
297    /// A schema declares `outline` together with `title` or `sections`.
298    ConflictingOutline,
299    /// A frontmatter JSON Schema is malformed or uses an unsupported dialect.
300    InvalidFrontmatterSchema,
301}
302
303impl SchemaErrorKind {
304    /// Returns the stable diagnostic spelling defined by specification §6.
305    pub const fn as_str(self) -> &'static str {
306        match self {
307            Self::Syntax => "syntax",
308            Self::InvalidDocumentShape => "invalid-document-shape",
309            Self::UnsupportedVersion => "unsupported-version",
310            Self::DuplicateId => "duplicate-id",
311            Self::UnresolvedRef => "unresolved-ref",
312            Self::ForbiddenRef => "forbidden-ref",
313            Self::DuplicateRef => "duplicate-ref",
314            Self::ReservedId => "reserved-id",
315            Self::InvalidMatcher => "invalid-matcher",
316            Self::InvalidRepeat => "invalid-repeat",
317            Self::OrderedScopeMismatch => "ordered-scope-mismatch",
318            Self::ConflictingCardinality => "conflicting-cardinality",
319            Self::ConflictingFrontmatter => "conflicting-frontmatter",
320            Self::ConflictingOutline => "conflicting-outline",
321            Self::InvalidFrontmatterSchema => "invalid-frontmatter-schema",
322        }
323    }
324}
325
326impl fmt::Display for SchemaErrorKind {
327    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
328        formatter.write_str(self.as_str())
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::SchemaErrorKind;
335
336    #[test]
337    fn schema_error_ids_use_the_public_spellings() {
338        let expected = [
339            (SchemaErrorKind::Syntax, "syntax"),
340            (
341                SchemaErrorKind::InvalidDocumentShape,
342                "invalid-document-shape",
343            ),
344            (SchemaErrorKind::UnsupportedVersion, "unsupported-version"),
345            (SchemaErrorKind::DuplicateId, "duplicate-id"),
346            (SchemaErrorKind::UnresolvedRef, "unresolved-ref"),
347            (SchemaErrorKind::ForbiddenRef, "forbidden-ref"),
348            (SchemaErrorKind::DuplicateRef, "duplicate-ref"),
349            (SchemaErrorKind::ReservedId, "reserved-id"),
350            (SchemaErrorKind::InvalidMatcher, "invalid-matcher"),
351            (SchemaErrorKind::InvalidRepeat, "invalid-repeat"),
352            (
353                SchemaErrorKind::OrderedScopeMismatch,
354                "ordered-scope-mismatch",
355            ),
356            (
357                SchemaErrorKind::ConflictingCardinality,
358                "conflicting-cardinality",
359            ),
360            (
361                SchemaErrorKind::ConflictingFrontmatter,
362                "conflicting-frontmatter",
363            ),
364            (SchemaErrorKind::ConflictingOutline, "conflicting-outline"),
365            (
366                SchemaErrorKind::InvalidFrontmatterSchema,
367                "invalid-frontmatter-schema",
368            ),
369        ];
370        for (kind, spelling) in expected {
371            assert_eq!(kind.as_str(), spelling);
372        }
373    }
374}