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}