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