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::ParseDocument;
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// ============================================================================
162// Primitive Type Schemas
163// ============================================================================
164
165/// Boundary condition for numeric constraints
166///
167/// Uses ADT to prevent invalid states (e.g., both inclusive and exclusive)
168#[derive(Debug, Clone, PartialEq, Default)]
169pub enum Bound<T> {
170 /// No constraint (-∞ or +∞)
171 #[default]
172 Unbounded,
173 /// Inclusive bound (≤ or ≥)
174 Inclusive(T),
175 /// Exclusive bound (< or >)
176 Exclusive(T),
177}
178
179/// Text type constraints
180///
181/// The `language` field determines what kind of text is expected:
182/// - `None` - accepts any text (no language constraint)
183/// - `Some("plaintext")` - expects plaintext (from `"..."` syntax or `Language::Plaintext`)
184/// - `Some("rust")` - expects Rust code (from `` rust`...` `` syntax or `Language::Other("rust")`)
185///
186/// # Schema Syntax
187///
188/// - `.text` - any text (language=None)
189/// - `.text.X` - text with language X (e.g., `.text.rust`, `.text.email`)
190///
191/// # Validation Rules
192///
193/// When validating a `Text` value against a `TextSchema`:
194/// - `Language::Plaintext` matches schema with `language=None` or `language=Some("plaintext")`
195/// - `Language::Implicit` matches any schema (the schema's language is applied)
196/// - `Language::Other(lang)` matches schema with `language=None` or `language=Some(lang)`
197///
198/// ```eure
199/// @variants.text
200/// language = .text (optional) # e.g., "rust", "email", "markdown"
201/// min-length = .integer (optional)
202/// max-length = .integer (optional)
203/// pattern = .text (optional)
204/// ```
205#[derive(Debug, Clone, Default, ParseDocument)]
206#[eure(crate = eure_document, rename_all = "kebab-case", allow_unknown_fields, allow_unknown_extensions)]
207pub struct TextSchema {
208 /// Language identifier (e.g., "rust", "javascript", "email", "plaintext")
209 ///
210 /// - `None` - accepts any text regardless of language
211 /// - `Some(lang)` - expects text with the specific language tag
212 ///
213 /// Note: When a value has `Language::Implicit` (from `` `...` `` syntax),
214 /// it can be coerced to match the schema's expected language.
215 #[eure(default)]
216 pub language: Option<String>,
217 /// Minimum length constraint (in UTF-8 code points)
218 #[eure(default)]
219 pub min_length: Option<u32>,
220 /// Maximum length constraint (in UTF-8 code points)
221 #[eure(default)]
222 pub max_length: Option<u32>,
223 /// Regex pattern constraint (applied to the text content).
224 /// Pre-compiled at schema parse time for efficiency.
225 #[eure(default)]
226 pub pattern: Option<Regex>,
227 /// Unknown fields (for future extensions like "flatten")
228 #[eure(flatten)]
229 pub unknown_fields: IndexMap<String, eure_document::document::NodeId>,
230}
231
232impl PartialEq for TextSchema {
233 fn eq(&self, other: &Self) -> bool {
234 self.language == other.language
235 && self.min_length == other.min_length
236 && self.max_length == other.max_length
237 && self.unknown_fields == other.unknown_fields
238 && match (&self.pattern, &other.pattern) {
239 (None, None) => true,
240 (Some(a), Some(b)) => a.as_str() == b.as_str(),
241 _ => false,
242 }
243 }
244}
245
246/// Integer type constraints
247///
248/// Spec: lines 360-364
249/// ```eure
250/// @variants.integer
251/// range = .$types.range-string (optional)
252/// multiple-of = .integer (optional)
253/// ```
254///
255/// Note: Range string is parsed in the converter to Bound<BigInt>
256#[derive(Debug, Clone, Default, PartialEq)]
257pub struct IntegerSchema {
258 /// Minimum value constraint (parsed from range string)
259 pub min: Bound<BigInt>,
260 /// Maximum value constraint (parsed from range string)
261 pub max: Bound<BigInt>,
262 /// Multiple-of constraint
263 pub multiple_of: Option<BigInt>,
264}
265
266/// Float precision specifier
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
268pub enum FloatPrecision {
269 /// 32-bit floating point (f32)
270 F32,
271 /// 64-bit floating point (f64) - default
272 #[default]
273 F64,
274}
275
276/// Float type constraints
277///
278/// Spec: lines 371-375
279/// ```eure
280/// @variants.float
281/// range = .$types.range-string (optional)
282/// multiple-of = .float (optional)
283/// precision = "f32" | "f64" (optional, default: "f64")
284/// ```
285///
286/// Note: Range string is parsed in the converter to Bound<f64>
287#[derive(Debug, Clone, Default, PartialEq)]
288pub struct FloatSchema {
289 /// Minimum value constraint (parsed from range string)
290 pub min: Bound<f64>,
291 /// Maximum value constraint (parsed from range string)
292 pub max: Bound<f64>,
293 /// Multiple-of constraint
294 pub multiple_of: Option<f64>,
295 /// Float precision (f32 or f64)
296 pub precision: FloatPrecision,
297}
298
299// ============================================================================
300// Compound Type Schemas
301// ============================================================================
302
303/// Array type constraints
304///
305/// Spec: lines 426-439
306/// ```eure
307/// @variants.array
308/// item = .$types.type
309/// min-length = .integer (optional)
310/// max-length = .integer (optional)
311/// unique = .boolean (optional)
312/// contains = .$types.type (optional)
313/// $ext-type.binding-style = .$types.binding-style (optional)
314/// ```
315#[derive(Debug, Clone, PartialEq)]
316pub struct ArraySchema {
317 /// Schema for array elements (required)
318 pub item: SchemaNodeId,
319 /// Minimum number of elements
320 pub min_length: Option<u32>,
321 /// Maximum number of elements
322 pub max_length: Option<u32>,
323 /// All elements must be unique
324 pub unique: bool,
325 /// Array must contain at least one element matching this schema
326 pub contains: Option<SchemaNodeId>,
327 /// Binding style for formatting
328 pub binding_style: Option<BindingStyle>,
329}
330
331/// Map type constraints
332///
333/// Spec: lines 453-459
334/// ```eure
335/// @variants.map
336/// key = .$types.type
337/// value = .$types.type
338/// min-size = .integer (optional)
339/// max-size = .integer (optional)
340/// ```
341#[derive(Debug, Clone, PartialEq)]
342pub struct MapSchema {
343 /// Schema for keys
344 pub key: SchemaNodeId,
345 /// Schema for values
346 pub value: SchemaNodeId,
347 /// Minimum number of key-value pairs
348 pub min_size: Option<u32>,
349 /// Maximum number of key-value pairs
350 pub max_size: Option<u32>,
351}
352
353/// Record field with per-field metadata
354///
355/// Spec: lines 401-410 (value extensions)
356/// ```eure
357/// value.$ext-type.optional = .boolean (optional)
358/// value.$ext-type.binding-style = .$types.binding-style (optional)
359/// ```
360#[derive(Debug, Clone, PartialEq)]
361pub struct RecordFieldSchema {
362 /// Schema for this field's value
363 pub schema: SchemaNodeId,
364 /// Field is optional (defaults to false = required)
365 pub optional: bool,
366 /// Binding style for this field
367 pub binding_style: Option<BindingStyle>,
368}
369
370/// Record type with fixed named fields
371///
372/// Spec: lines 401-410
373/// ```eure
374/// @variants.record
375/// $variant: map
376/// key = .text
377/// value = .$types.type
378/// $ext-type.unknown-fields = .$types.unknown-fields-policy (optional)
379/// ```
380#[derive(Debug, Clone, Default, PartialEq)]
381pub struct RecordSchema {
382 /// Fixed field schemas (field name -> field schema with metadata)
383 pub properties: IndexMap<String, RecordFieldSchema>,
384 /// Policy for unknown/additional fields (default: deny)
385 pub unknown_fields: UnknownFieldsPolicy,
386}
387
388/// Policy for handling fields not defined in record properties
389///
390/// Spec: lines 240-251
391/// ```eure
392/// @ $types.unknown-fields-policy
393/// @variants.deny = "deny"
394/// @variants.allow = "allow"
395/// @variants.schema = .$types.type
396/// ```
397#[derive(Debug, Clone, Default, PartialEq)]
398pub enum UnknownFieldsPolicy {
399 /// Deny unknown fields (default, strict)
400 #[default]
401 Deny,
402 /// Allow any unknown fields without validation
403 Allow,
404 /// Unknown fields must match this schema
405 Schema(SchemaNodeId),
406}
407
408/// Tuple type with fixed-length ordered elements
409///
410/// Spec: lines 465-468
411/// ```eure
412/// @variants.tuple
413/// elements = [.$types.type]
414/// $ext-type.binding-style = .$types.binding-style (optional)
415/// ```
416#[derive(Debug, Clone, PartialEq)]
417pub struct TupleSchema {
418 /// Schema for each element by position
419 pub elements: Vec<SchemaNodeId>,
420 /// Binding style for formatting
421 pub binding_style: Option<BindingStyle>,
422}
423
424/// Union type with named variants
425///
426/// Spec: lines 415-423
427/// ```eure
428/// @variants.union
429/// variants = { $variant: map, key => .text, value => .$types.type }
430/// $ext-type.variant-repr = .$types.variant-repr (optional)
431/// ```
432#[derive(Debug, Clone, PartialEq)]
433pub struct UnionSchema {
434 /// Variant definitions (variant name -> schema)
435 pub variants: IndexMap<String, SchemaNodeId>,
436 /// Variants that use unambiguous semantics (try all, detect conflicts).
437 /// All other variants use short-circuit semantics (first match wins).
438 pub unambiguous: IndexSet<String>,
439 /// Variant representation strategy (default: External)
440 pub repr: VariantRepr,
441 /// Variants that deny untagged matching (require explicit $variant)
442 pub deny_untagged: IndexSet<String>,
443}
444
445// ============================================================================
446// Binding Style
447// ============================================================================
448
449/// How to represent document paths in formatted output
450///
451/// Spec: lines 263-296
452/// ```eure
453/// @ $types.binding-style
454/// $variant: union
455/// variants { auto, passthrough, section, nested, binding, section-binding, section-root-binding }
456/// ```
457#[derive(Debug, Clone, PartialEq, Eq, Default, ParseDocument)]
458#[eure(crate = ::eure_document, rename_all = "kebab-case")]
459pub enum BindingStyle {
460 /// Automatically determine the best representation
461 #[default]
462 Auto,
463 /// Pass through; defer to subsequent keys
464 Passthrough,
465 /// Create a new section (@ a.b.c)
466 Section,
467 /// Create a nested section (@ a.b.c { ... })
468 Nested,
469 /// Bind value (a.b.c = value)
470 Binding,
471 /// Section with block (a.b.c { ... })
472 SectionBinding,
473 /// Section with root binding (@ a.b.c = value)
474 SectionRootBinding,
475}
476
477// ============================================================================
478// Type Reference
479// ============================================================================
480
481/// Type reference (local or cross-schema)
482///
483/// - Local reference: `$types.my-type`
484/// - Cross-schema reference: `$types.namespace.type-name`
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct TypeReference {
487 /// Namespace for cross-schema references (None for local refs)
488 pub namespace: Option<String>,
489 /// Type name
490 pub name: Identifier,
491}
492
493// ============================================================================
494// Metadata
495// ============================================================================
496
497/// Description can be plain string or markdown
498///
499/// Spec: lines 312-316
500/// ```eure
501/// description => { $variant: union, variants.string => .text, variants.markdown => .text.markdown }
502/// ```
503#[derive(Debug, Clone, PartialEq, ParseDocument)]
504#[eure(crate = eure_document, rename_all = "lowercase")]
505pub enum Description {
506 /// Plain text description
507 String(String),
508 /// Markdown formatted description
509 Markdown(String),
510}
511
512/// Schema metadata (available at any nesting level via $ext-type on $types.type)
513///
514/// ```eure
515/// description => union { string, .text.markdown } (optional)
516/// deprecated => .boolean (optional)
517/// default => .any (optional)
518/// examples => [`any`] (optional)
519/// ```
520///
521/// Note: `optional` and `binding_style` are per-field extensions stored in `RecordFieldSchema`
522#[derive(Debug, Clone, Default, PartialEq)]
523pub struct SchemaMetadata {
524 /// Documentation/description
525 pub description: Option<Description>,
526 /// Marks as deprecated
527 pub deprecated: bool,
528 /// Default value for optional fields
529 pub default: Option<EureDocument>,
530 /// Example values as Eure documents
531 pub examples: Option<Vec<EureDocument>>,
532}
533
534// ============================================================================
535// Implementation
536// ============================================================================
537
538impl SchemaDocument {
539 /// Create a new empty schema document
540 pub fn new() -> Self {
541 Self {
542 nodes: vec![SchemaNode {
543 content: SchemaNodeContent::Any,
544 metadata: SchemaMetadata::default(),
545 ext_types: IndexMap::new(),
546 }],
547 root: SchemaNodeId(0),
548 types: IndexMap::new(),
549 }
550 }
551
552 /// Get a reference to a node
553 pub fn node(&self, id: SchemaNodeId) -> &SchemaNode {
554 &self.nodes[id.0]
555 }
556
557 /// Get a mutable reference to a node
558 pub fn node_mut(&mut self, id: SchemaNodeId) -> &mut SchemaNode {
559 &mut self.nodes[id.0]
560 }
561
562 /// Create a new node and return its ID
563 pub fn create_node(&mut self, content: SchemaNodeContent) -> SchemaNodeId {
564 let id = SchemaNodeId(self.nodes.len());
565 self.nodes.push(SchemaNode {
566 content,
567 metadata: SchemaMetadata::default(),
568 ext_types: IndexMap::new(),
569 });
570 id
571 }
572
573 /// Register a named type
574 pub fn register_type(&mut self, name: Identifier, node_id: SchemaNodeId) {
575 self.types.insert(name, node_id);
576 }
577
578 /// Look up a named type
579 pub fn get_type(&self, name: &Identifier) -> Option<SchemaNodeId> {
580 self.types.get(name).copied()
581 }
582}
583
584impl Default for SchemaDocument {
585 fn default() -> Self {
586 Self::new()
587 }
588}
589
590// ============================================================================
591// Schema Reference
592// ============================================================================
593
594/// Reference to a schema file from `$schema` extension.
595///
596/// This type is used to extract the schema path from a document's root node.
597/// The `$schema` extension specifies the path to the schema file that should
598/// be used to validate the document.
599///
600/// # Example
601///
602/// ```eure
603/// $schema = "./person.schema.eure"
604/// name = "John"
605/// age = 30
606/// ```
607#[derive(Debug, Clone)]
608pub struct SchemaRef {
609 /// Path to the schema file
610 pub path: String,
611 /// NodeId where the $schema was defined (for error reporting)
612 pub node_id: eure_document::document::NodeId,
613}