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}