Skip to main content

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}