eure_schema/lib.rs
1//! Eure Schema types and structures
2//!
3//! This library provides schema type definitions for Eure documents,
4//! following the specification in `assets/eure-schema.schema.eure`.
5//!
6//! # Type Variants
7//!
8//! All types are variants of `SchemaNodeContent`:
9//!
10//! **Primitives:**
11//! - `Text` - Text type with optional language and length/pattern constraints
12//! - `Integer` - Integer type with optional range and multiple-of constraints
13//! - `Float` - Float type with optional range and multiple-of constraints
14//! - `Boolean` - Boolean type (no constraints)
15//! - `Null` - Null type
16//! - `Any` - Any type (accepts any value)
17//!
18//! **Literal:**
19//! - `Literal` - Exact value match (e.g., `status = "active"`)
20//!
21//! **Compounds:**
22//! - `Record` - Fixed named fields
23//! - `Array` - Ordered list with item type
24//! - `Map` - Dynamic key-value pairs
25//! - `Tuple` - Fixed-length ordered elements
26//! - `Union` - Tagged union with named variants
27//!
28//! **Reference:**
29//! - `Reference` - Type reference (local or cross-schema)
30
31pub mod build;
32pub mod convert;
33pub mod identifiers;
34pub mod parse;
35pub mod synth;
36pub mod validate;
37
38pub use build::{BuildSchema, SchemaBuilder};
39
40use eure_document::data_model::VariantRepr;
41use eure_document::document::EureDocument;
42use eure_document::identifier::Identifier;
43use eure_macros::FromEure;
44use indexmap::{IndexMap, IndexSet};
45use num_bigint::BigInt;
46use regex::Regex;
47
48// ============================================================================
49// Schema Document
50// ============================================================================
51
52/// Schema document with arena-based node storage
53#[derive(Debug, Clone, PartialEq)]
54pub struct SchemaDocument {
55 /// All schema nodes stored in a flat vector
56 pub nodes: Vec<SchemaNode>,
57 /// Root node reference
58 pub root: SchemaNodeId,
59 /// Named type definitions ($types)
60 pub types: IndexMap<Identifier, SchemaNodeId>,
61}
62
63/// Extension type definition with optionality
64#[derive(Debug, Clone, PartialEq)]
65pub struct ExtTypeSchema {
66 /// Schema for the extension value
67 pub schema: SchemaNodeId,
68 /// Whether the extension is optional (default: false = required)
69 pub optional: bool,
70}
71
72/// Reference to a schema node by index
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub struct SchemaNodeId(pub usize);
75
76/// A single schema node
77#[derive(Debug, Clone, PartialEq)]
78pub struct SchemaNode {
79 /// The type definition, structure, and constraints
80 pub content: SchemaNodeContent,
81 /// Cascading metadata (description, deprecated, default, examples)
82 pub metadata: SchemaMetadata,
83 /// Extension type definitions for this node ($ext-type.X)
84 pub ext_types: IndexMap<Identifier, ExtTypeSchema>,
85}
86
87// ============================================================================
88// Schema Node Content
89// ============================================================================
90
91/// Type definitions with their specific constraints
92///
93/// See spec: `eure-schema.schema.eure` lines 298-525
94#[derive(Debug, Clone, PartialEq)]
95pub enum SchemaNodeContent {
96 // --- Primitives ---
97 /// Any type - accepts any valid Eure value
98 /// Spec: line 391
99 Any,
100
101 /// Text type
102 ///
103 /// # Language Matching
104 ///
105 /// When validating text values:
106 /// - `Language::Plaintext` (from `"..."`) must match `.text` schema only
107 /// - `Language::Implicit` (from `` `...` ``) can be coerced to any language by schema
108 /// - `Language::Other(lang)` (from `` lang`...` ``) must match `.text.{lang}` schema
109 ///
110 /// Spec: lines 333-349
111 Text(TextSchema),
112
113 /// Integer type with optional constraints
114 /// Spec: lines 360-364
115 Integer(IntegerSchema),
116
117 /// Float type with optional constraints
118 /// Spec: lines 371-375
119 Float(FloatSchema),
120
121 /// Boolean type (no constraints)
122 /// Spec: line 383
123 Boolean,
124
125 /// Null type
126 /// Spec: line 387
127 Null,
128
129 // --- Literal ---
130 /// Literal type - accepts only the exact specified value
131 /// Spec: line 396
132 Literal(EureDocument),
133
134 // --- Compounds ---
135 /// Array type with item schema and optional constraints
136 /// Spec: lines 426-439
137 Array(ArraySchema),
138
139 /// Map type with dynamic keys
140 /// Spec: lines 453-459
141 Map(MapSchema),
142
143 /// Record type with fixed named fields
144 /// Spec: lines 401-410
145 Record(RecordSchema),
146
147 /// Tuple type with fixed-length ordered elements
148 /// Spec: lines 465-468
149 Tuple(TupleSchema),
150
151 /// Union type with named variants
152 /// Spec: lines 415-423
153 Union(UnionSchema),
154
155 // --- Reference ---
156 /// Type reference (local or cross-schema)
157 /// Spec: lines 506-510
158 Reference(TypeReference),
159}
160
161/// The kind of a schema node (discriminant without data).
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
163pub enum SchemaKind {
164 Any,
165 Text,
166 Integer,
167 Float,
168 Boolean,
169 Null,
170 Literal,
171 Array,
172 Map,
173 Record,
174 Tuple,
175 Union,
176 Reference,
177}
178
179impl std::fmt::Display for SchemaKind {
180 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181 let name = match self {
182 Self::Any => "any",
183 Self::Text => "text",
184 Self::Integer => "integer",
185 Self::Float => "float",
186 Self::Boolean => "boolean",
187 Self::Null => "null",
188 Self::Literal => "literal",
189 Self::Array => "array",
190 Self::Map => "map",
191 Self::Record => "record",
192 Self::Tuple => "tuple",
193 Self::Union => "union",
194 Self::Reference => "reference",
195 };
196 write!(f, "{}", name)
197 }
198}
199
200impl SchemaNodeContent {
201 /// Returns the kind of this schema node.
202 pub fn kind(&self) -> SchemaKind {
203 match self {
204 Self::Any => SchemaKind::Any,
205 Self::Text(_) => SchemaKind::Text,
206 Self::Integer(_) => SchemaKind::Integer,
207 Self::Float(_) => SchemaKind::Float,
208 Self::Boolean => SchemaKind::Boolean,
209 Self::Null => SchemaKind::Null,
210 Self::Literal(_) => SchemaKind::Literal,
211 Self::Array(_) => SchemaKind::Array,
212 Self::Map(_) => SchemaKind::Map,
213 Self::Record(_) => SchemaKind::Record,
214 Self::Tuple(_) => SchemaKind::Tuple,
215 Self::Union(_) => SchemaKind::Union,
216 Self::Reference(_) => SchemaKind::Reference,
217 }
218 }
219}
220
221// ============================================================================
222// Primitive Type Schemas
223// ============================================================================
224
225/// Boundary condition for numeric constraints
226///
227/// Uses ADT to prevent invalid states (e.g., both inclusive and exclusive)
228#[derive(Debug, Clone, PartialEq, Default)]
229pub enum Bound<T> {
230 /// No constraint (-∞ or +∞)
231 #[default]
232 Unbounded,
233 /// Inclusive bound (≤ or ≥)
234 Inclusive(T),
235 /// Exclusive bound (< or >)
236 Exclusive(T),
237}
238
239/// Text type constraints
240///
241/// The `language` field determines what kind of text is expected:
242/// - `None` - accepts any text (no language constraint)
243/// - `Some("plaintext")` - expects plaintext (from `"..."` syntax or `Language::Plaintext`)
244/// - `Some("rust")` - expects Rust code (from `` rust`...` `` syntax or `Language::Other("rust")`)
245///
246/// # Schema Syntax
247///
248/// - `.text` - any text (language=None)
249/// - `.text.X` - text with language X (e.g., `.text.rust`, `.text.email`)
250///
251/// # Validation Rules
252///
253/// When validating a `Text` value against a `TextSchema`:
254/// - `Language::Plaintext` matches schema with `language=None` or `language=Some("plaintext")`
255/// - `Language::Implicit` matches any schema (the schema's language is applied)
256/// - `Language::Other(lang)` matches schema with `language=None` or `language=Some(lang)`
257///
258/// ```eure
259/// @variants.text
260/// language = .text (optional) # e.g., "rust", "email", "markdown"
261/// min-length = .integer (optional)
262/// max-length = .integer (optional)
263/// pattern = .text (optional)
264/// ```
265#[derive(Debug, Clone, Default, FromEure)]
266#[eure(crate = eure_document, rename_all = "kebab-case", allow_unknown_fields, allow_unknown_extensions)]
267pub struct TextSchema {
268 /// Language identifier (e.g., "rust", "javascript", "email", "plaintext")
269 ///
270 /// - `None` - accepts any text regardless of language
271 /// - `Some(lang)` - expects text with the specific language tag
272 ///
273 /// Note: When a value has `Language::Implicit` (from `` `...` `` syntax),
274 /// it can be coerced to match the schema's expected language.
275 #[eure(default)]
276 pub language: Option<String>,
277 /// Minimum length constraint (in UTF-8 code points)
278 #[eure(default)]
279 pub min_length: Option<u32>,
280 /// Maximum length constraint (in UTF-8 code points)
281 #[eure(default)]
282 pub max_length: Option<u32>,
283 /// Regex pattern constraint (applied to the text content).
284 /// Pre-compiled at schema parse time for efficiency.
285 #[eure(default)]
286 pub pattern: Option<Regex>,
287 /// Unknown fields (for future extensions like "flatten")
288 #[eure(flatten)]
289 pub unknown_fields: IndexMap<String, eure_document::document::NodeId>,
290}
291
292impl PartialEq for TextSchema {
293 fn eq(&self, other: &Self) -> bool {
294 self.language == other.language
295 && self.min_length == other.min_length
296 && self.max_length == other.max_length
297 && self.unknown_fields == other.unknown_fields
298 && match (&self.pattern, &other.pattern) {
299 (None, None) => true,
300 (Some(a), Some(b)) => a.as_str() == b.as_str(),
301 _ => false,
302 }
303 }
304}
305
306/// Integer type constraints
307///
308/// Spec: lines 360-364
309/// ```eure
310/// @variants.integer
311/// range = .$types.range-string (optional)
312/// multiple-of = .integer (optional)
313/// ```
314///
315/// Note: Range string is parsed in the converter to Bound<BigInt>
316#[derive(Debug, Clone, Default, PartialEq)]
317pub struct IntegerSchema {
318 /// Minimum value constraint (parsed from range string)
319 pub min: Bound<BigInt>,
320 /// Maximum value constraint (parsed from range string)
321 pub max: Bound<BigInt>,
322 /// Multiple-of constraint
323 pub multiple_of: Option<BigInt>,
324}
325
326/// Float precision specifier
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
328pub enum FloatPrecision {
329 /// 32-bit floating point (f32)
330 F32,
331 /// 64-bit floating point (f64) - default
332 #[default]
333 F64,
334}
335
336/// Float type constraints
337///
338/// Spec: lines 371-375
339/// ```eure
340/// @variants.float
341/// range = .$types.range-string (optional)
342/// multiple-of = .float (optional)
343/// precision = "f32" | "f64" (optional, default: "f64")
344/// ```
345///
346/// Note: Range string is parsed in the converter to Bound<f64>
347#[derive(Debug, Clone, Default, PartialEq)]
348pub struct FloatSchema {
349 /// Minimum value constraint (parsed from range string)
350 pub min: Bound<f64>,
351 /// Maximum value constraint (parsed from range string)
352 pub max: Bound<f64>,
353 /// Multiple-of constraint
354 pub multiple_of: Option<f64>,
355 /// Float precision (f32 or f64)
356 pub precision: FloatPrecision,
357}
358
359// ============================================================================
360// Compound Type Schemas
361// ============================================================================
362
363/// Array type constraints
364///
365/// Spec: lines 426-439
366/// ```eure
367/// @variants.array
368/// item = .$types.type
369/// min-length = .integer (optional)
370/// max-length = .integer (optional)
371/// unique = .boolean (optional)
372/// contains = .$types.type (optional)
373/// $ext-type.binding-style = .$types.binding-style (optional)
374/// ```
375#[derive(Debug, Clone, PartialEq)]
376pub struct ArraySchema {
377 /// Schema for array elements (required)
378 pub item: SchemaNodeId,
379 /// Minimum number of elements
380 pub min_length: Option<u32>,
381 /// Maximum number of elements
382 pub max_length: Option<u32>,
383 /// All elements must be unique
384 pub unique: bool,
385 /// Array must contain at least one element matching this schema
386 pub contains: Option<SchemaNodeId>,
387 /// Binding style for formatting
388 pub binding_style: Option<BindingStyle>,
389}
390
391/// Map type constraints
392///
393/// Spec: lines 453-459
394/// ```eure
395/// @variants.map
396/// key = .$types.type
397/// value = .$types.type
398/// min-size = .integer (optional)
399/// max-size = .integer (optional)
400/// ```
401#[derive(Debug, Clone, PartialEq)]
402pub struct MapSchema {
403 /// Schema for keys
404 pub key: SchemaNodeId,
405 /// Schema for values
406 pub value: SchemaNodeId,
407 /// Minimum number of key-value pairs
408 pub min_size: Option<u32>,
409 /// Maximum number of key-value pairs
410 pub max_size: Option<u32>,
411}
412
413/// Record field with per-field metadata
414///
415/// Spec: lines 401-410 (value extensions)
416/// ```eure
417/// value.$ext-type.optional = .boolean (optional)
418/// value.$ext-type.binding-style = .$types.binding-style (optional)
419/// ```
420#[derive(Debug, Clone, PartialEq)]
421pub struct RecordFieldSchema {
422 /// Schema for this field's value
423 pub schema: SchemaNodeId,
424 /// Field is optional (defaults to false = required)
425 pub optional: bool,
426 /// Binding style for this field
427 pub binding_style: Option<BindingStyle>,
428}
429
430/// Record type with fixed named fields
431///
432/// Spec: lines 401-410
433/// ```eure
434/// @variants.record
435/// $variant: map
436/// key = .text
437/// value = .$types.type
438/// $ext-type.unknown-fields = .$types.unknown-fields-policy (optional)
439/// ```
440#[derive(Debug, Clone, Default, PartialEq)]
441pub struct RecordSchema {
442 /// Fixed field schemas (field name -> field schema with metadata)
443 pub properties: IndexMap<String, RecordFieldSchema>,
444 /// Schemas to flatten into this record.
445 /// Each must point to a Record or Union schema.
446 /// Fields from flattened schemas are merged into this record's field space.
447 pub flatten: Vec<SchemaNodeId>,
448 /// Policy for unknown/additional fields (default: deny)
449 pub unknown_fields: UnknownFieldsPolicy,
450}
451
452/// Policy for handling fields not defined in record properties
453///
454/// Spec: lines 240-251
455/// ```eure
456/// @ $types.unknown-fields-policy
457/// @variants.deny = "deny"
458/// @variants.allow = "allow"
459/// @variants.schema = .$types.type
460/// ```
461#[derive(Debug, Clone, Default, PartialEq)]
462pub enum UnknownFieldsPolicy {
463 /// Deny unknown fields (default, strict)
464 #[default]
465 Deny,
466 /// Allow any unknown fields without validation
467 Allow,
468 /// Unknown fields must match this schema
469 Schema(SchemaNodeId),
470}
471
472/// Tuple type with fixed-length ordered elements
473///
474/// Spec: lines 465-468
475/// ```eure
476/// @variants.tuple
477/// elements = [.$types.type]
478/// $ext-type.binding-style = .$types.binding-style (optional)
479/// ```
480#[derive(Debug, Clone, PartialEq)]
481pub struct TupleSchema {
482 /// Schema for each element by position
483 pub elements: Vec<SchemaNodeId>,
484 /// Binding style for formatting
485 pub binding_style: Option<BindingStyle>,
486}
487
488/// Union type with named variants
489///
490/// Spec: lines 415-423
491/// ```eure
492/// @variants.union
493/// variants = { $variant: map, key => .text, value => .$types.type }
494/// $ext-type.variant-repr = .$types.variant-repr (optional)
495/// ```
496#[derive(Debug, Clone, PartialEq)]
497pub struct UnionSchema {
498 /// Variant definitions (variant name -> schema)
499 pub variants: IndexMap<String, SchemaNodeId>,
500 /// Variants that use unambiguous semantics (try all, detect conflicts).
501 /// All other variants use short-circuit semantics (first match wins).
502 pub unambiguous: IndexSet<String>,
503 /// Variant representation strategy (default: External)
504 pub repr: VariantRepr,
505 /// Variants that deny untagged matching (require explicit $variant)
506 pub deny_untagged: IndexSet<String>,
507}
508
509// ============================================================================
510// Binding Style
511// ============================================================================
512
513/// How to represent document paths in formatted output
514///
515/// Spec: lines 263-296
516/// ```eure
517/// @ $types.binding-style
518/// $variant: union
519/// variants { auto, passthrough, section, nested, binding, section-binding, section-root-binding }
520/// ```
521#[derive(Debug, Clone, PartialEq, Eq, Default, FromEure)]
522#[eure(crate = ::eure_document, rename_all = "kebab-case")]
523pub enum BindingStyle {
524 /// Automatically determine the best representation
525 #[default]
526 Auto,
527 /// Pass through; defer to subsequent keys
528 Passthrough,
529 /// Create a new section (@ a.b.c)
530 Section,
531 /// Create a nested section (@ a.b.c { ... })
532 Nested,
533 /// Bind value (a.b.c = value)
534 Binding,
535 /// Section with block (a.b.c { ... })
536 SectionBinding,
537 /// Section with root binding (@ a.b.c = value)
538 SectionRootBinding,
539}
540
541// ============================================================================
542// Type Reference
543// ============================================================================
544
545/// Type reference (local or cross-schema)
546///
547/// - Local reference: `$types.my-type`
548/// - Cross-schema reference: `$types.namespace.type-name`
549#[derive(Debug, Clone, PartialEq, Eq)]
550pub struct TypeReference {
551 /// Namespace for cross-schema references (None for local refs)
552 pub namespace: Option<String>,
553 /// Type name
554 pub name: Identifier,
555}
556
557// ============================================================================
558// Metadata
559// ============================================================================
560
561/// Description can be plain string or markdown
562///
563/// Spec: lines 312-316
564/// ```eure
565/// description => { $variant: union, variants.string => .text, variants.markdown => .text.markdown }
566/// ```
567#[derive(Debug, Clone, PartialEq, FromEure)]
568#[eure(crate = eure_document, rename_all = "lowercase")]
569pub enum Description {
570 /// Plain text description
571 String(String),
572 /// Markdown formatted description
573 Markdown(String),
574}
575
576/// Schema metadata (available at any nesting level via $ext-type on $types.type)
577///
578/// ```eure
579/// description => union { string, .text.markdown } (optional)
580/// deprecated => .boolean (optional)
581/// default => .any (optional)
582/// examples => [`any`] (optional)
583/// ```
584///
585/// Note: `optional` and `binding_style` are per-field extensions stored in `RecordFieldSchema`
586#[derive(Debug, Clone, Default, PartialEq)]
587pub struct SchemaMetadata {
588 /// Documentation/description
589 pub description: Option<Description>,
590 /// Marks as deprecated
591 pub deprecated: bool,
592 /// Default value for optional fields
593 pub default: Option<EureDocument>,
594 /// Example values as Eure documents
595 pub examples: Option<Vec<EureDocument>>,
596}
597
598// ============================================================================
599// Implementation
600// ============================================================================
601
602impl SchemaDocument {
603 /// Create a new empty schema document
604 pub fn new() -> Self {
605 Self {
606 nodes: vec![SchemaNode {
607 content: SchemaNodeContent::Any,
608 metadata: SchemaMetadata::default(),
609 ext_types: IndexMap::new(),
610 }],
611 root: SchemaNodeId(0),
612 types: IndexMap::new(),
613 }
614 }
615
616 /// Get a reference to a node
617 pub fn node(&self, id: SchemaNodeId) -> &SchemaNode {
618 &self.nodes[id.0]
619 }
620
621 /// Get a mutable reference to a node
622 pub fn node_mut(&mut self, id: SchemaNodeId) -> &mut SchemaNode {
623 &mut self.nodes[id.0]
624 }
625
626 /// Create a new node and return its ID
627 pub fn create_node(&mut self, content: SchemaNodeContent) -> SchemaNodeId {
628 let id = SchemaNodeId(self.nodes.len());
629 self.nodes.push(SchemaNode {
630 content,
631 metadata: SchemaMetadata::default(),
632 ext_types: IndexMap::new(),
633 });
634 id
635 }
636
637 /// Register a named type
638 pub fn register_type(&mut self, name: Identifier, node_id: SchemaNodeId) {
639 self.types.insert(name, node_id);
640 }
641
642 /// Look up a named type
643 pub fn get_type(&self, name: &Identifier) -> Option<SchemaNodeId> {
644 self.types.get(name).copied()
645 }
646}
647
648impl Default for SchemaDocument {
649 fn default() -> Self {
650 Self::new()
651 }
652}
653
654// ============================================================================
655// Schema Reference
656// ============================================================================
657
658/// Reference to a schema file from `$schema` extension.
659///
660/// This type is used to extract the schema path from a document's root node.
661/// The `$schema` extension specifies the path to the schema file that should
662/// be used to validate the document.
663///
664/// # Example
665///
666/// ```eure
667/// $schema = "./person.schema.eure"
668/// name = "John"
669/// age = 30
670/// ```
671#[derive(Debug, Clone)]
672pub struct SchemaRef {
673 /// Path to the schema file
674 pub path: String,
675 /// NodeId where the $schema was defined (for error reporting)
676 pub node_id: eure_document::document::NodeId,
677}