Skip to main content

eure_schema/
convert.rs

1//! Conversion from EureDocument to SchemaDocument
2//!
3//! This module provides functionality to convert Eure documents containing schema definitions
4//! into SchemaDocument structures.
5//!
6//! # Schema Syntax
7//!
8//! Schema types are defined using the following syntax:
9//!
10//! **Primitives (shorthands via inline code):**
11//! - `` `text` ``, `` `integer` ``, `` `float` ``, `` `boolean` ``, `` `null` ``, `` `any` ``
12//! - `` `text.rust` ``, `` `text.email` ``, `` `text.plaintext` ``
13//!
14//! **Primitives with constraints:**
15//! ```eure
16//! @ field {
17//!   $variant = "text"
18//!   min-length = 3
19//!   max-length = 20
20//!   pattern = `^[a-z]+$`
21//! }
22//! ```
23//!
24//! **Array:** `` [`text`] `` or `` { $variant = "array", item = `text`, ... } ``
25//!
26//! **Tuple:** `` (`text`, `integer`) `` or `{ $variant = "tuple", elements = [...] }`
27//!
28//! **Record:** `` { name = `text`, age = `integer` } ``
29//!
30//! **Union with named variants:**
31//! ```eure
32//! @ field {
33//!   $variant = "union"
34//!   variants.success = { data = `any` }
35//!   variants.error = { message = `text` }
36//!   variants.error.$ext-type.unambiguous = true  // optional, for catch-all variants
37//!   $interop.variant-repr = "untagged"  // optional
38//! }
39//! ```
40//!
41//! **Literal:** Any constant value (e.g., `{ = "active", $variant = "literal" }`, `42`, `true`)
42//!
43//! **Type reference:** `` `$types.my-type` `` or `` `$types.namespace.type` ``
44
45use crate::parse::{
46    ParsedArraySchema, ParsedExtTypeSchema, ParsedFloatSchema, ParsedIntegerSchema,
47    ParsedMapSchema, ParsedRecordSchema, ParsedSchemaMetadata, ParsedSchemaNode,
48    ParsedSchemaNodeContent, ParsedTupleSchema, ParsedUnionSchema, ParsedUnknownFieldsPolicy,
49};
50use crate::type_path_trace::LayoutStrategies;
51use crate::{
52    ArraySchema, Bound, CodegenDefaults, ExtTypeSchema, FloatPrecision, FloatSchema, IntegerSchema,
53    MapSchema, RecordCodegen, RecordFieldSchema, RecordSchema, RootCodegen, SchemaDocument,
54    SchemaMetadata, SchemaNodeContent, SchemaNodeId, TupleSchema, TypeCodegen, UnionCodegen,
55    UnionSchema, UnknownFieldsPolicy,
56};
57use eure_document::document::node::{Node, NodeValue};
58use eure_document::document::{EureDocument, InsertErrorKind, NodeId};
59use eure_document::identifier::Identifier;
60use eure_document::parse::ParseError;
61use eure_document::path::{EurePath, PathSegment};
62use eure_document::value::{ObjectKey, ValueKind};
63use indexmap::IndexMap;
64use num_bigint::BigInt;
65use thiserror::Error;
66
67/// Errors that can occur during document to schema conversion
68#[derive(Debug, Error, Clone, PartialEq)]
69pub enum ConversionError {
70    #[error("Invalid type name: {0}")]
71    InvalidTypeName(ObjectKey),
72
73    #[error("unsupported literal value at node {node_id:?}: {kind}")]
74    UnsupportedLiteralValue { node_id: NodeId, kind: ValueKind },
75
76    #[error("document insert error while copying literal value: {0}")]
77    DocumentInsert(#[from] InsertErrorKind),
78
79    #[error("Invalid extension value: {extension} at path {path}")]
80    InvalidExtensionValue { extension: String, path: String },
81
82    #[error("Invalid range string: {0}")]
83    InvalidRangeString(String),
84
85    #[error("Invalid precision: {0} (expected \"f32\" or \"f64\")")]
86    InvalidPrecision(String),
87
88    #[error("Undefined type reference: {0}")]
89    UndefinedTypeReference(String),
90
91    #[error("non-productive reference cycle detected: {0}")]
92    NonProductiveReferenceCycle(String),
93
94    #[error(
95        "invalid `$codegen` extension at node {node_id:?}: supported only for record/union, got {schema_kind}"
96    )]
97    InvalidTypeCodegenTarget {
98        node_id: NodeId,
99        schema_kind: String,
100    },
101
102    #[error("Parse error: {0}")]
103    ParseError(#[from] ParseError),
104}
105
106/// Mapping from schema node IDs to their source document node IDs.
107/// Used for propagating origin information for error formatting.
108pub type SchemaSourceMap = IndexMap<SchemaNodeId, NodeId>;
109
110/// Internal converter state
111struct Converter<'a> {
112    doc: &'a EureDocument,
113    schema: SchemaDocument,
114    /// Track source document NodeId for each schema node
115    source_map: SchemaSourceMap,
116}
117
118impl<'a> Converter<'a> {
119    fn new(doc: &'a EureDocument) -> Self {
120        Self {
121            doc,
122            schema: SchemaDocument::new(),
123            source_map: IndexMap::new(),
124        }
125    }
126
127    /// Convert the root node and produce the final schema with source mapping
128    fn convert(mut self) -> Result<(SchemaDocument, SchemaSourceMap), ConversionError> {
129        let root_id = self.doc.get_root_id();
130        let root_node = self.doc.node(root_id);
131
132        // Convert all type definitions from $types extension
133        self.convert_types(root_node)?;
134        self.convert_root_codegen(root_node)?;
135
136        // Convert root node
137        // Root-level `$codegen` is handled separately via `convert_root_codegen`.
138        // If root content is not record/union, keep it as root metadata instead of
139        // treating it as invalid type-level codegen.
140        self.schema.root = self.convert_node_allow_non_type_codegen(root_id)?;
141
142        // Validate all type references exist
143        self.validate_type_references()?;
144        self.validate_non_productive_reference_cycles()?;
145
146        Ok((self.schema, self.source_map))
147    }
148
149    fn convert_root_codegen(&mut self, node: &Node) -> Result<(), ConversionError> {
150        let codegen_ident: Identifier = "codegen".parse().unwrap();
151        let codegen_defaults_ident: Identifier = "codegen-defaults".parse().unwrap();
152
153        if let Some(node_id) = node.extensions.get(&codegen_ident) {
154            let rec = self.doc.parse_record(*node_id)?;
155            self.schema.root_codegen = RootCodegen {
156                type_name: rec.parse_field_optional::<String>("type")?,
157            };
158        }
159
160        if let Some(node_id) = node.extensions.get(&codegen_defaults_ident) {
161            self.schema.codegen_defaults = self.doc.parse::<CodegenDefaults>(*node_id)?;
162        }
163
164        Ok(())
165    }
166
167    /// Convert all type definitions from $types extension
168    fn convert_types(&mut self, node: &Node) -> Result<(), ConversionError> {
169        let types_ident: Identifier = "types".parse().unwrap();
170        if let Some(types_node_id) = node.extensions.get(&types_ident) {
171            let types_node = self.doc.node(*types_node_id);
172            if let NodeValue::Map(map) = &types_node.content {
173                for (key, &node_id) in map.iter() {
174                    if let ObjectKey::String(name) = key {
175                        let type_name: Identifier = name
176                            .parse()
177                            .map_err(|_| ConversionError::InvalidTypeName(key.clone()))?;
178                        let schema_id = self.convert_node(node_id)?;
179                        self.schema.types.insert(type_name, schema_id);
180                    } else {
181                        return Err(ConversionError::InvalidTypeName(key.clone()));
182                    }
183                }
184            } else {
185                return Err(ConversionError::InvalidExtensionValue {
186                    extension: "types".to_string(),
187                    path: "$types must be a map".to_string(),
188                });
189            }
190        }
191        Ok(())
192    }
193
194    /// Validate that all type references point to defined types
195    fn validate_type_references(&self) -> Result<(), ConversionError> {
196        for node in &self.schema.nodes {
197            if let SchemaNodeContent::Reference(type_ref) = &node.content
198                && type_ref.namespace.is_none()
199                && !self.schema.types.contains_key(&type_ref.name)
200            {
201                return Err(ConversionError::UndefinedTypeReference(
202                    type_ref.name.to_string(),
203                ));
204            }
205        }
206        Ok(())
207    }
208
209    fn validate_non_productive_reference_cycles(&self) -> Result<(), ConversionError> {
210        #[derive(Clone, Copy, PartialEq, Eq)]
211        enum Mark {
212            Visiting,
213            Done,
214        }
215
216        fn next_ref_target(schema: &SchemaDocument, node_id: SchemaNodeId) -> Option<SchemaNodeId> {
217            let node = schema.node(node_id);
218            let SchemaNodeContent::Reference(type_ref) = &node.content else {
219                return None;
220            };
221            if type_ref.namespace.is_some() {
222                return None;
223            }
224            schema.get_type(&type_ref.name)
225        }
226
227        fn display_node(schema: &SchemaDocument, id: SchemaNodeId) -> String {
228            if let Some((name, _)) = schema.types.iter().find(|(_, sid)| **sid == id) {
229                format!("$types.{}", name)
230            } else {
231                format!("node#{}", id.0)
232            }
233        }
234
235        fn visit(
236            schema: &SchemaDocument,
237            node_id: SchemaNodeId,
238            marks: &mut IndexMap<SchemaNodeId, Mark>,
239            stack: &mut Vec<SchemaNodeId>,
240        ) -> Result<(), ConversionError> {
241            if matches!(marks.get(&node_id), Some(Mark::Done)) {
242                return Ok(());
243            }
244            if matches!(marks.get(&node_id), Some(Mark::Visiting)) {
245                let start = stack.iter().position(|sid| *sid == node_id).unwrap_or(0);
246                let mut cycle: Vec<String> = stack[start..]
247                    .iter()
248                    .map(|sid| display_node(schema, *sid))
249                    .collect();
250                cycle.push(display_node(schema, node_id));
251                return Err(ConversionError::NonProductiveReferenceCycle(
252                    cycle.join(" -> "),
253                ));
254            }
255
256            marks.insert(node_id, Mark::Visiting);
257            stack.push(node_id);
258            if let Some(next_id) = next_ref_target(schema, node_id) {
259                visit(schema, next_id, marks, stack)?;
260            }
261            stack.pop();
262            marks.insert(node_id, Mark::Done);
263            Ok(())
264        }
265
266        let mut marks = IndexMap::new();
267        let mut stack = Vec::new();
268        for index in 0..self.schema.nodes.len() {
269            visit(&self.schema, SchemaNodeId(index), &mut marks, &mut stack)?;
270        }
271        Ok(())
272    }
273
274    /// Convert a document node to a schema node using FromEure trait
275    fn convert_node(&mut self, node_id: NodeId) -> Result<SchemaNodeId, ConversionError> {
276        self.convert_node_inner(node_id, false)
277    }
278
279    fn convert_node_allow_non_type_codegen(
280        &mut self,
281        node_id: NodeId,
282    ) -> Result<SchemaNodeId, ConversionError> {
283        self.convert_node_inner(node_id, true)
284    }
285
286    fn convert_node_inner(
287        &mut self,
288        node_id: NodeId,
289        allow_non_type_codegen: bool,
290    ) -> Result<SchemaNodeId, ConversionError> {
291        // Parse the node using FromEure trait
292        let parsed: ParsedSchemaNode = self.doc.parse(node_id)?;
293        let ParsedSchemaNode {
294            content: parsed_content,
295            metadata: parsed_metadata,
296            ext_types: parsed_ext_types,
297            codegen: parsed_codegen,
298        } = parsed;
299
300        // Convert the parsed node to final schema
301        let content = self.convert_content(parsed_content)?;
302        let metadata = self.convert_metadata(parsed_metadata)?;
303        let ext_types = self.convert_ext_types(parsed_ext_types)?;
304        let type_codegen =
305            self.convert_type_codegen(parsed_codegen, &content, allow_non_type_codegen)?;
306
307        // Create the final schema node
308        let schema_id = self.schema.create_node(content);
309        let schema_node = self.schema.node_mut(schema_id);
310        schema_node.metadata = metadata;
311        schema_node.ext_types = ext_types;
312        schema_node.type_codegen = type_codegen;
313
314        // Record source mapping for span resolution
315        self.source_map.insert(schema_id, node_id);
316        Ok(schema_id)
317    }
318
319    fn convert_type_codegen(
320        &self,
321        codegen_node_id: Option<NodeId>,
322        content: &SchemaNodeContent,
323        allow_non_type_codegen: bool,
324    ) -> Result<TypeCodegen, ConversionError> {
325        let Some(codegen_node_id) = codegen_node_id else {
326            return Ok(TypeCodegen::None);
327        };
328
329        if allow_non_type_codegen
330            && !matches!(
331                content,
332                SchemaNodeContent::Record(_) | SchemaNodeContent::Union(_)
333            )
334        {
335            return Ok(TypeCodegen::None);
336        }
337
338        match content {
339            SchemaNodeContent::Union(_) => Ok(TypeCodegen::Union(
340                self.doc.parse::<UnionCodegen>(codegen_node_id)?,
341            )),
342            _ => Ok(TypeCodegen::Record(
343                self.doc.parse::<RecordCodegen>(codegen_node_id)?,
344            )),
345        }
346    }
347
348    /// Convert parsed schema node content to final schema node content
349    fn convert_content(
350        &mut self,
351        content: ParsedSchemaNodeContent,
352    ) -> Result<SchemaNodeContent, ConversionError> {
353        match content {
354            ParsedSchemaNodeContent::Any => Ok(SchemaNodeContent::Any),
355            ParsedSchemaNodeContent::Boolean => Ok(SchemaNodeContent::Boolean),
356            ParsedSchemaNodeContent::Null => Ok(SchemaNodeContent::Null),
357            ParsedSchemaNodeContent::Text(schema) => Ok(SchemaNodeContent::Text(schema)),
358            ParsedSchemaNodeContent::Reference(type_ref) => {
359                Ok(SchemaNodeContent::Reference(type_ref))
360            }
361
362            ParsedSchemaNodeContent::Integer(parsed) => Ok(SchemaNodeContent::Integer(
363                self.convert_integer_schema(parsed)?,
364            )),
365            ParsedSchemaNodeContent::Float(parsed) => {
366                Ok(SchemaNodeContent::Float(self.convert_float_schema(parsed)?))
367            }
368            ParsedSchemaNodeContent::Literal(node_id) => {
369                Ok(SchemaNodeContent::Literal(self.node_to_document(node_id)?))
370            }
371            ParsedSchemaNodeContent::Array(parsed) => {
372                Ok(SchemaNodeContent::Array(self.convert_array_schema(parsed)?))
373            }
374            ParsedSchemaNodeContent::Map(parsed) => {
375                Ok(SchemaNodeContent::Map(self.convert_map_schema(parsed)?))
376            }
377            ParsedSchemaNodeContent::Record(parsed) => Ok(SchemaNodeContent::Record(
378                self.convert_record_schema(parsed)?,
379            )),
380            ParsedSchemaNodeContent::Tuple(parsed) => {
381                Ok(SchemaNodeContent::Tuple(self.convert_tuple_schema(parsed)?))
382            }
383            ParsedSchemaNodeContent::Union(parsed) => {
384                Ok(SchemaNodeContent::Union(self.convert_union_schema(parsed)?))
385            }
386        }
387    }
388
389    /// Convert parsed integer schema (with range string) to final integer schema (with Bound)
390    fn convert_integer_schema(
391        &self,
392        parsed: ParsedIntegerSchema,
393    ) -> Result<IntegerSchema, ConversionError> {
394        let (min, max) = if let Some(range_str) = &parsed.range {
395            parse_integer_range(range_str)?
396        } else {
397            (Bound::Unbounded, Bound::Unbounded)
398        };
399
400        Ok(IntegerSchema {
401            min,
402            max,
403            multiple_of: parsed.multiple_of,
404        })
405    }
406
407    /// Convert parsed float schema (with range string) to final float schema (with Bound)
408    fn convert_float_schema(
409        &self,
410        parsed: ParsedFloatSchema,
411    ) -> Result<FloatSchema, ConversionError> {
412        let (min, max) = if let Some(range_str) = &parsed.range {
413            parse_float_range(range_str)?
414        } else {
415            (Bound::Unbounded, Bound::Unbounded)
416        };
417
418        let precision = match parsed.precision.as_deref() {
419            Some("f32") => FloatPrecision::F32,
420            Some("f64") | None => FloatPrecision::F64,
421            Some(other) => {
422                return Err(ConversionError::InvalidPrecision(other.to_string()));
423            }
424        };
425
426        Ok(FloatSchema {
427            min,
428            max,
429            multiple_of: parsed.multiple_of,
430            precision,
431        })
432    }
433
434    /// Convert parsed array schema to final array schema
435    fn convert_array_schema(
436        &mut self,
437        parsed: ParsedArraySchema,
438    ) -> Result<ArraySchema, ConversionError> {
439        let item = self.convert_node(parsed.item)?;
440        let contains = parsed
441            .contains
442            .map(|id| self.convert_node(id))
443            .transpose()?;
444
445        Ok(ArraySchema {
446            item,
447            min_length: parsed.min_length,
448            max_length: parsed.max_length,
449            unique: parsed.unique,
450            contains,
451            binding_style: parsed.binding_style,
452        })
453    }
454
455    /// Convert parsed map schema to final map schema
456    fn convert_map_schema(
457        &mut self,
458        parsed: ParsedMapSchema,
459    ) -> Result<MapSchema, ConversionError> {
460        let key = self.convert_node(parsed.key)?;
461        let value = self.convert_node(parsed.value)?;
462
463        Ok(MapSchema {
464            key,
465            value,
466            min_size: parsed.min_size,
467            max_size: parsed.max_size,
468        })
469    }
470
471    /// Convert parsed tuple schema to final tuple schema
472    fn convert_tuple_schema(
473        &mut self,
474        parsed: ParsedTupleSchema,
475    ) -> Result<TupleSchema, ConversionError> {
476        let elements: Vec<SchemaNodeId> = parsed
477            .elements
478            .iter()
479            .map(|&id| self.convert_node(id))
480            .collect::<Result<_, _>>()?;
481
482        Ok(TupleSchema {
483            elements,
484            binding_style: parsed.binding_style,
485        })
486    }
487
488    /// Convert parsed record schema to final record schema
489    fn convert_record_schema(
490        &mut self,
491        parsed: ParsedRecordSchema,
492    ) -> Result<RecordSchema, ConversionError> {
493        let mut properties = IndexMap::new();
494
495        for (field_name, field_parsed) in parsed.properties {
496            let schema = self.convert_node_allow_non_type_codegen(field_parsed.schema)?;
497            properties.insert(
498                field_name,
499                RecordFieldSchema {
500                    schema,
501                    optional: field_parsed.optional,
502                    binding_style: field_parsed.binding_style,
503                    field_codegen: field_parsed.codegen.unwrap_or_default(),
504                },
505            );
506        }
507
508        // Convert flatten targets
509        let flatten = parsed
510            .flatten
511            .into_iter()
512            .map(|id| self.convert_node(id))
513            .collect::<Result<Vec<_>, _>>()?;
514
515        let unknown_fields = self.convert_unknown_fields_policy(parsed.unknown_fields)?;
516
517        Ok(RecordSchema {
518            properties,
519            flatten,
520            unknown_fields,
521        })
522    }
523
524    /// Convert parsed union schema to final union schema
525    fn convert_union_schema(
526        &mut self,
527        parsed: ParsedUnionSchema,
528    ) -> Result<UnionSchema, ConversionError> {
529        let ParsedUnionSchema {
530            variants: parsed_variants,
531            unambiguous,
532            interop,
533            deny_untagged,
534        } = parsed;
535        let mut variants = IndexMap::new();
536
537        for (variant_name, variant_node_id) in parsed_variants {
538            let schema = self.convert_node(variant_node_id)?;
539            variants.insert(variant_name, schema);
540        }
541
542        Ok(UnionSchema {
543            variants,
544            unambiguous,
545            interop,
546            deny_untagged,
547        })
548    }
549
550    /// Convert parsed unknown fields policy to final policy
551    fn convert_unknown_fields_policy(
552        &mut self,
553        parsed: ParsedUnknownFieldsPolicy,
554    ) -> Result<UnknownFieldsPolicy, ConversionError> {
555        match parsed {
556            ParsedUnknownFieldsPolicy::Deny => Ok(UnknownFieldsPolicy::Deny),
557            ParsedUnknownFieldsPolicy::Allow => Ok(UnknownFieldsPolicy::Allow),
558            ParsedUnknownFieldsPolicy::Schema(node_id) => {
559                let schema = self.convert_node(node_id)?;
560                Ok(UnknownFieldsPolicy::Schema(schema))
561            }
562        }
563    }
564
565    /// Convert parsed metadata to final metadata
566    fn convert_metadata(
567        &mut self,
568        parsed: ParsedSchemaMetadata,
569    ) -> Result<SchemaMetadata, ConversionError> {
570        let default = parsed
571            .default
572            .map(|id| self.node_to_document(id))
573            .transpose()?;
574
575        let examples = parsed
576            .examples
577            .map(|ids| {
578                ids.into_iter()
579                    .map(|id| self.node_to_document(id))
580                    .collect::<Result<Vec<_>, _>>()
581            })
582            .transpose()?;
583
584        Ok(SchemaMetadata {
585            description: parsed.description,
586            deprecated: parsed.deprecated,
587            default,
588            examples,
589        })
590    }
591
592    /// Convert parsed ext types to final ext types
593    fn convert_ext_types(
594        &mut self,
595        parsed: IndexMap<Identifier, ParsedExtTypeSchema>,
596    ) -> Result<IndexMap<Identifier, ExtTypeSchema>, ConversionError> {
597        let mut result = IndexMap::new();
598
599        for (name, parsed_schema) in parsed {
600            let schema = self.convert_node(parsed_schema.schema)?;
601            result.insert(
602                name,
603                ExtTypeSchema {
604                    schema,
605                    optional: parsed_schema.optional,
606                    binding_style: parsed_schema.binding_style,
607                },
608            );
609        }
610
611        Ok(result)
612    }
613
614    /// Extract a subtree as a new EureDocument for literal types
615    fn node_to_document(&self, node_id: NodeId) -> Result<EureDocument, ConversionError> {
616        let mut new_doc = EureDocument::new();
617        let root_id = new_doc.get_root_id();
618        self.copy_node_to(&mut new_doc, root_id, node_id)?;
619        Ok(new_doc)
620    }
621
622    /// Recursively copy a node from source document to destination
623    fn copy_node_to(
624        &self,
625        dest: &mut EureDocument,
626        dest_node_id: NodeId,
627        src_node_id: NodeId,
628    ) -> Result<(), ConversionError> {
629        let src_node = self.doc.node(src_node_id);
630
631        // Collect child info before mutating dest
632        let children_to_copy: Vec<_> = match &src_node.content {
633            NodeValue::Primitive(prim) => {
634                dest.set_content(dest_node_id, NodeValue::Primitive(prim.clone()));
635                vec![]
636            }
637            NodeValue::Array(arr) => {
638                dest.set_content(dest_node_id, NodeValue::empty_array());
639                arr.to_vec()
640            }
641            NodeValue::Tuple(tup) => {
642                dest.set_content(dest_node_id, NodeValue::empty_tuple());
643                tup.to_vec()
644            }
645            NodeValue::Map(map) => {
646                dest.set_content(dest_node_id, NodeValue::empty_map());
647                map.iter()
648                    .map(|(k, &v)| (k.clone(), v))
649                    .collect::<Vec<_>>()
650                    .into_iter()
651                    .map(|(_, v)| v)
652                    .collect()
653            }
654            NodeValue::PartialMap(_) => {
655                return Err(ConversionError::UnsupportedLiteralValue {
656                    node_id: src_node_id,
657                    kind: ValueKind::PartialMap,
658                });
659            }
660            NodeValue::Hole(_) => {
661                return Err(ConversionError::UnsupportedLiteralValue {
662                    node_id: src_node_id,
663                    kind: ValueKind::Hole,
664                });
665            }
666        };
667
668        // Skip ALL extensions during literal value copying.
669        // Extensions are schema metadata (like $variant, $deny-untagged, $optional, etc.)
670        // and should not be part of the literal value comparison.
671        // Literal types compare only the data structure, not metadata.
672
673        // Now copy children based on the type
674        let src_node = self.doc.node(src_node_id);
675        match &src_node.content {
676            NodeValue::Array(_) => {
677                for child_id in children_to_copy {
678                    let new_child_id = dest.add_array_element(None, dest_node_id)?.node_id;
679                    self.copy_node_to(dest, new_child_id, child_id)?;
680                }
681            }
682            NodeValue::Tuple(_) => {
683                for (index, child_id) in children_to_copy.into_iter().enumerate() {
684                    let new_child_id = dest.add_tuple_element(index as u8, dest_node_id)?.node_id;
685                    self.copy_node_to(dest, new_child_id, child_id)?;
686                }
687            }
688            NodeValue::Map(map) => {
689                for (key, &child_id) in map.iter() {
690                    let new_child_id = dest.add_map_child(key.clone(), dest_node_id)?.node_id;
691                    self.copy_node_to(dest, new_child_id, child_id)?;
692                }
693            }
694            _ => {}
695        }
696
697        Ok(())
698    }
699}
700
701/// Parse an integer range string (Rust-style or interval notation)
702fn parse_integer_range(s: &str) -> Result<(Bound<BigInt>, Bound<BigInt>), ConversionError> {
703    let s = s.trim();
704
705    // Try interval notation first: [a, b], (a, b), [a, b), (a, b]
706    if s.starts_with('[') || s.starts_with('(') {
707        return parse_interval_integer(s);
708    }
709
710    // Rust-style: a..b, a..=b, a.., ..b, ..=b
711    if let Some(eq_pos) = s.find("..=") {
712        let left = &s[..eq_pos];
713        let right = &s[eq_pos + 3..];
714        let min = if left.is_empty() {
715            Bound::Unbounded
716        } else {
717            Bound::Inclusive(parse_bigint(left)?)
718        };
719        let max = if right.is_empty() {
720            Bound::Unbounded
721        } else {
722            Bound::Inclusive(parse_bigint(right)?)
723        };
724        Ok((min, max))
725    } else if let Some(dot_pos) = s.find("..") {
726        let left = &s[..dot_pos];
727        let right = &s[dot_pos + 2..];
728        let min = if left.is_empty() {
729            Bound::Unbounded
730        } else {
731            Bound::Inclusive(parse_bigint(left)?)
732        };
733        let max = if right.is_empty() {
734            Bound::Unbounded
735        } else {
736            Bound::Exclusive(parse_bigint(right)?)
737        };
738        Ok((min, max))
739    } else {
740        Err(ConversionError::InvalidRangeString(s.to_string()))
741    }
742}
743
744/// Parse interval notation for integers: [a, b], (a, b), etc.
745fn parse_interval_integer(s: &str) -> Result<(Bound<BigInt>, Bound<BigInt>), ConversionError> {
746    let left_inclusive = s.starts_with('[');
747    let right_inclusive = s.ends_with(']');
748
749    let inner = &s[1..s.len() - 1];
750    let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
751    if parts.len() != 2 {
752        return Err(ConversionError::InvalidRangeString(s.to_string()));
753    }
754
755    let min = if parts[0].is_empty() {
756        Bound::Unbounded
757    } else if left_inclusive {
758        Bound::Inclusive(parse_bigint(parts[0])?)
759    } else {
760        Bound::Exclusive(parse_bigint(parts[0])?)
761    };
762
763    let max = if parts[1].is_empty() {
764        Bound::Unbounded
765    } else if right_inclusive {
766        Bound::Inclusive(parse_bigint(parts[1])?)
767    } else {
768        Bound::Exclusive(parse_bigint(parts[1])?)
769    };
770
771    Ok((min, max))
772}
773
774/// Parse a float range string
775fn parse_float_range(s: &str) -> Result<(Bound<f64>, Bound<f64>), ConversionError> {
776    let s = s.trim();
777
778    // Try interval notation first
779    if s.starts_with('[') || s.starts_with('(') {
780        return parse_interval_float(s);
781    }
782
783    // Rust-style
784    if let Some(eq_pos) = s.find("..=") {
785        let left = &s[..eq_pos];
786        let right = &s[eq_pos + 3..];
787        let min = if left.is_empty() {
788            Bound::Unbounded
789        } else {
790            Bound::Inclusive(parse_f64(left)?)
791        };
792        let max = if right.is_empty() {
793            Bound::Unbounded
794        } else {
795            Bound::Inclusive(parse_f64(right)?)
796        };
797        Ok((min, max))
798    } else if let Some(dot_pos) = s.find("..") {
799        let left = &s[..dot_pos];
800        let right = &s[dot_pos + 2..];
801        let min = if left.is_empty() {
802            Bound::Unbounded
803        } else {
804            Bound::Inclusive(parse_f64(left)?)
805        };
806        let max = if right.is_empty() {
807            Bound::Unbounded
808        } else {
809            Bound::Exclusive(parse_f64(right)?)
810        };
811        Ok((min, max))
812    } else {
813        Err(ConversionError::InvalidRangeString(s.to_string()))
814    }
815}
816
817/// Parse interval notation for floats
818fn parse_interval_float(s: &str) -> Result<(Bound<f64>, Bound<f64>), ConversionError> {
819    let left_inclusive = s.starts_with('[');
820    let right_inclusive = s.ends_with(']');
821
822    let inner = &s[1..s.len() - 1];
823    let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
824    if parts.len() != 2 {
825        return Err(ConversionError::InvalidRangeString(s.to_string()));
826    }
827
828    let min = if parts[0].is_empty() {
829        Bound::Unbounded
830    } else if left_inclusive {
831        Bound::Inclusive(parse_f64(parts[0])?)
832    } else {
833        Bound::Exclusive(parse_f64(parts[0])?)
834    };
835
836    let max = if parts[1].is_empty() {
837        Bound::Unbounded
838    } else if right_inclusive {
839        Bound::Inclusive(parse_f64(parts[1])?)
840    } else {
841        Bound::Exclusive(parse_f64(parts[1])?)
842    };
843
844    Ok((min, max))
845}
846
847fn collect_document_node_paths(doc: &EureDocument) -> IndexMap<NodeId, EurePath> {
848    fn dfs(
849        doc: &EureDocument,
850        node_id: NodeId,
851        path: &mut Vec<PathSegment>,
852        out: &mut IndexMap<NodeId, EurePath>,
853        visited: &mut std::collections::HashSet<NodeId>,
854    ) {
855        if !visited.insert(node_id) {
856            return;
857        }
858        out.insert(node_id, EurePath(path.clone()));
859
860        let node = doc.node(node_id);
861        for (ext, &child_id) in node.extensions.iter() {
862            path.push(PathSegment::Extension(ext.clone()));
863            dfs(doc, child_id, path, out, visited);
864            path.pop();
865        }
866        match &node.content {
867            NodeValue::Array(array) => {
868                for (index, &child_id) in array.iter().enumerate() {
869                    path.push(PathSegment::ArrayIndex(Some(index)));
870                    dfs(doc, child_id, path, out, visited);
871                    path.pop();
872                }
873            }
874            NodeValue::Tuple(tuple) => {
875                for (index, &child_id) in tuple.iter().enumerate() {
876                    path.push(PathSegment::TupleIndex(index as u8));
877                    dfs(doc, child_id, path, out, visited);
878                    path.pop();
879                }
880            }
881            NodeValue::Map(map) => {
882                for (key, &child_id) in map.iter() {
883                    path.push(PathSegment::Value(key.clone()));
884                    dfs(doc, child_id, path, out, visited);
885                    path.pop();
886                }
887            }
888            NodeValue::PartialMap(map) => {
889                for (key, &child_id) in map.iter() {
890                    path.push(PathSegment::from_partial_object_key(key.clone()));
891                    dfs(doc, child_id, path, out, visited);
892                    path.pop();
893                }
894            }
895            NodeValue::Primitive(_) | NodeValue::Hole(_) => {}
896        }
897    }
898
899    let mut out = IndexMap::new();
900    let mut path = Vec::new();
901    let mut visited = std::collections::HashSet::new();
902    dfs(doc, doc.get_root_id(), &mut path, &mut out, &mut visited);
903    out
904}
905
906fn schema_node_fallback_path(schema_id: SchemaNodeId) -> EurePath {
907    EurePath(vec![PathSegment::Value(ObjectKey::String(format!(
908        "schema-node-{}",
909        schema_id.0
910    )))])
911}
912
913fn build_layout_strategies(
914    schema: &SchemaDocument,
915    source_map: &SchemaSourceMap,
916    source_node_paths: &IndexMap<NodeId, EurePath>,
917) -> LayoutStrategies {
918    let mut layout = LayoutStrategies::default();
919
920    for (schema_id, source_node_id) in source_map {
921        if let Some(path) = source_node_paths.get(source_node_id) {
922            layout.schema_node_paths.insert(*schema_id, path.clone());
923        }
924    }
925
926    for schema_index in 0..schema.nodes.len() {
927        let schema_id = SchemaNodeId(schema_index);
928        let schema_node = schema.node(schema_id);
929
930        let node_path = layout
931            .schema_node_paths
932            .get(&schema_id)
933            .cloned()
934            .unwrap_or_else(|| schema_node_fallback_path(schema_id));
935
936        if let SchemaNodeContent::Array(array_schema) = &schema_node.content
937            && let Some(style) = array_schema.binding_style
938        {
939            layout.by_path.insert(node_path.clone(), style);
940        }
941        if let SchemaNodeContent::Tuple(tuple_schema) = &schema_node.content
942            && let Some(style) = tuple_schema.binding_style
943        {
944            layout.by_path.insert(node_path.clone(), style);
945        }
946
947        if let SchemaNodeContent::Record(record_schema) = &schema_node.content {
948            let mut order = Vec::new();
949            for (field_name, field_schema) in &record_schema.properties {
950                order.push(PathSegment::Value(ObjectKey::String(field_name.clone())));
951                if let Some(style) = field_schema.binding_style {
952                    let child_path = layout
953                        .schema_node_paths
954                        .get(&field_schema.schema)
955                        .cloned()
956                        .unwrap_or_else(|| schema_node_fallback_path(field_schema.schema));
957                    layout.by_path.insert(child_path, style);
958                }
959            }
960            for ext_name in schema_node.ext_types.keys() {
961                order.push(PathSegment::Extension(ext_name.clone()));
962            }
963            if !order.is_empty() {
964                layout.order_by_path.insert(node_path.clone(), order);
965            }
966        }
967
968        for ext_schema in schema_node.ext_types.values() {
969            if let Some(style) = ext_schema.binding_style {
970                let ext_path = layout
971                    .schema_node_paths
972                    .get(&ext_schema.schema)
973                    .cloned()
974                    .unwrap_or_else(|| schema_node_fallback_path(ext_schema.schema));
975                layout.by_path.insert(ext_path, style);
976            }
977        }
978    }
979
980    layout
981}
982
983fn parse_bigint(s: &str) -> Result<BigInt, ConversionError> {
984    s.parse()
985        .map_err(|_| ConversionError::InvalidRangeString(format!("Invalid integer: {}", s)))
986}
987
988fn parse_f64(s: &str) -> Result<f64, ConversionError> {
989    s.parse()
990        .map_err(|_| ConversionError::InvalidRangeString(format!("Invalid float: {}", s)))
991}
992
993/// Convert an EureDocument containing schema definitions to a SchemaDocument
994///
995/// This function traverses the document and extracts schema information from:
996/// - Type paths (`.text`, `.integer`, `.text.rust`, etc.)
997/// - `$variant` extension for explicit type variants
998/// - `variants.*` fields for union variant definitions
999/// - Constraint fields (`min-length`, `max-length`, `pattern`, `range`, etc.)
1000/// - Metadata extensions (`$description`, `$deprecated`, `$default`, `$examples`)
1001///
1002/// # Arguments
1003///
1004/// * `doc` - The EureDocument containing schema definitions
1005///
1006/// # Returns
1007///
1008/// A tuple of (SchemaDocument, SchemaSourceMap) on success, or a ConversionError on failure.
1009/// The SchemaSourceMap maps each schema node ID to its source document node ID, which can be
1010/// used for propagating origin information for error formatting.
1011///
1012/// # Examples
1013///
1014/// ```ignore
1015/// use eure::parse_to_document;
1016/// use eure_schema::convert::document_to_schema;
1017///
1018/// let input = r#"
1019/// name = `text`
1020/// age = `integer`
1021/// "#;
1022///
1023/// let doc = parse_to_document(input).unwrap();
1024/// let (schema, source_map) = document_to_schema(&doc).unwrap();
1025/// ```
1026pub fn document_to_schema_with_layout(
1027    doc: &EureDocument,
1028) -> Result<(SchemaDocument, LayoutStrategies, SchemaSourceMap), ConversionError> {
1029    let (schema, source_map) = Converter::new(doc).convert()?;
1030    let source_node_paths = collect_document_node_paths(doc);
1031    let layout = build_layout_strategies(&schema, &source_map, &source_node_paths);
1032    Ok((schema, layout, source_map))
1033}
1034
1035pub fn document_to_schema(
1036    doc: &EureDocument,
1037) -> Result<(SchemaDocument, SchemaSourceMap), ConversionError> {
1038    let (schema, _layout, source_map) = document_to_schema_with_layout(doc)?;
1039    Ok((schema, source_map))
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044    use super::*;
1045    use crate::identifiers::{EXT_TYPE, OPTIONAL};
1046    use eure_document::document::node::NodeMap;
1047    use eure_document::eure;
1048    use eure_document::text::Text;
1049    use eure_document::value::PrimitiveValue;
1050
1051    /// Create a document with a record containing a single field with $ext-type extension
1052    fn create_schema_with_field_ext_type(ext_type_content: NodeValue) -> EureDocument {
1053        let mut doc = EureDocument::new();
1054        let root_id = doc.get_root_id();
1055
1056        // Create field value: `text`
1057        let field_value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1058            Text::inline_implicit("text"),
1059        )));
1060
1061        // Add $ext-type extension to the field
1062        let ext_type_id = doc.create_node(ext_type_content);
1063        doc.node_mut(field_value_id)
1064            .extensions
1065            .insert(EXT_TYPE.clone(), ext_type_id);
1066
1067        // Create root as record with field: { name = `text` }
1068        let mut root_map = NodeMap::default();
1069        root_map.insert(ObjectKey::String("name".to_string()), field_value_id);
1070        doc.node_mut(root_id).content = NodeValue::Map(root_map);
1071
1072        doc
1073    }
1074
1075    #[test]
1076    fn extract_ext_types_not_map() {
1077        // name.$ext-type = 1 should error, not silently ignore
1078        // The new parser catches this during parse_record() which expects a map
1079        let doc = create_schema_with_field_ext_type(NodeValue::Primitive(PrimitiveValue::Integer(
1080            1.into(),
1081        )));
1082
1083        let err = document_to_schema(&doc).unwrap_err();
1084        use eure_document::parse::ParseErrorKind;
1085        use eure_document::value::ValueKind;
1086        assert_eq!(
1087            err,
1088            ConversionError::ParseError(ParseError {
1089                node_id: NodeId(2),
1090                kind: ParseErrorKind::TypeMismatch {
1091                    expected: ValueKind::Map,
1092                    actual: ValueKind::Integer,
1093                }
1094            })
1095        );
1096    }
1097
1098    #[test]
1099    fn extract_ext_types_invalid_key() {
1100        // name.$ext-type = { 0 => `text` } should error, not silently ignore
1101        // The parser catches this during parse_ext_types() -> unknown_fields()
1102        let mut doc = EureDocument::new();
1103        let root_id = doc.get_root_id();
1104
1105        // Create field value: `text`
1106        let field_value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1107            Text::inline_implicit("text"),
1108        )));
1109
1110        // Create $ext-type as map with integer key
1111        // The value's node_id is returned in the error since that's the entry with invalid key
1112        let ext_type_value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1113            Text::inline_implicit("text"),
1114        )));
1115        let mut ext_type_map = NodeMap::default();
1116        ext_type_map.insert(ObjectKey::Number(0.into()), ext_type_value_id);
1117
1118        let ext_type_id = doc.create_node(NodeValue::Map(ext_type_map));
1119        doc.node_mut(field_value_id)
1120            .extensions
1121            .insert(EXT_TYPE.clone(), ext_type_id);
1122
1123        // Create root as record
1124        let mut root_map = NodeMap::default();
1125        root_map.insert(ObjectKey::String("name".to_string()), field_value_id);
1126        doc.node_mut(root_id).content = NodeValue::Map(root_map);
1127
1128        let err = document_to_schema(&doc).unwrap_err();
1129        use eure_document::parse::ParseErrorKind;
1130        assert_eq!(
1131            err,
1132            ConversionError::ParseError(ParseError {
1133                // The error points to the value's node_id (the entry with invalid key)
1134                node_id: ext_type_value_id,
1135                kind: ParseErrorKind::InvalidKeyType(ObjectKey::Number(0.into()))
1136            })
1137        );
1138    }
1139
1140    #[test]
1141    fn extract_ext_types_invalid_optional() {
1142        // name.$ext-type.desc.$optional = 1 should error, not silently default to false
1143        // The new parser catches this during field_optional::<bool>() parsing
1144        let mut doc = EureDocument::new();
1145        let root_id = doc.get_root_id();
1146
1147        // Create field value: `text`
1148        let field_value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1149            Text::inline_implicit("text"),
1150        )));
1151
1152        // Create ext-type value with invalid $optional = 1
1153        let ext_type_value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1154            Text::inline_implicit("text"),
1155        )));
1156        let optional_node_id =
1157            doc.create_node(NodeValue::Primitive(PrimitiveValue::Integer(1.into())));
1158        doc.node_mut(ext_type_value_id)
1159            .extensions
1160            .insert(OPTIONAL.clone(), optional_node_id);
1161
1162        // Create $ext-type map
1163        let mut ext_type_map = NodeMap::default();
1164        ext_type_map.insert(ObjectKey::String("desc".to_string()), ext_type_value_id);
1165
1166        let ext_type_id = doc.create_node(NodeValue::Map(ext_type_map));
1167        doc.node_mut(field_value_id)
1168            .extensions
1169            .insert(EXT_TYPE.clone(), ext_type_id);
1170
1171        // Create root as record
1172        let mut root_map = NodeMap::default();
1173        root_map.insert(ObjectKey::String("name".to_string()), field_value_id);
1174        doc.node_mut(root_id).content = NodeValue::Map(root_map);
1175
1176        let err = document_to_schema(&doc).unwrap_err();
1177        use eure_document::parse::ParseErrorKind;
1178        use eure_document::value::ValueKind;
1179        assert_eq!(
1180            err,
1181            ConversionError::ParseError(ParseError {
1182                node_id: NodeId(3),
1183                kind: ParseErrorKind::TypeMismatch {
1184                    expected: ValueKind::Bool,
1185                    actual: ValueKind::Integer,
1186                }
1187            })
1188        );
1189    }
1190
1191    #[test]
1192    fn literal_variant_with_inline_code() {
1193        // Test: { = `any`, $variant => "literal" } should create Literal(Text("any"))
1194        // NOT Any (which would happen if $variant is not detected)
1195        // Note: { = value, $ext => ... } is represented in document model as just the value with extensions
1196        let mut doc = EureDocument::new();
1197        let root_id = doc.get_root_id();
1198
1199        // Create the $variant extension value: "literal"
1200        let variant_value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1201            Text::plaintext("literal"),
1202        )));
1203
1204        // Set root content to the inline code value directly: `any`
1205        // (not wrapped in a map, since { = value } unwraps to just value)
1206        doc.node_mut(root_id).content =
1207            NodeValue::Primitive(PrimitiveValue::Text(Text::inline_implicit("any")));
1208
1209        // Add $variant extension
1210        doc.node_mut(root_id)
1211            .extensions
1212            .insert("variant".parse().unwrap(), variant_value_id);
1213
1214        let (schema, _source_map) =
1215            document_to_schema(&doc).expect("Schema conversion should succeed");
1216
1217        // The root should be a Literal, not Any
1218        let root_content = &schema.node(schema.root).content;
1219        match root_content {
1220            SchemaNodeContent::Literal(doc) => {
1221                // The value should be Text("any")
1222                match &doc.root().content {
1223                    NodeValue::Primitive(PrimitiveValue::Text(t)) => {
1224                        assert_eq!(t.as_str(), "any", "Literal should contain 'any'");
1225                    }
1226                    _ => panic!("Expected Literal with Text primitive, got {:?}", doc),
1227                }
1228            }
1229            SchemaNodeContent::Any => {
1230                panic!("BUG: Got Any instead of Literal - $variant extension not detected!");
1231            }
1232            other => panic!("Expected Literal, got {:?}", other),
1233        }
1234    }
1235
1236    #[test]
1237    fn literal_variant_parsed_from_eure() {
1238        let doc = eure!({
1239            = @code("any")
1240            %variant = "literal"
1241        });
1242
1243        let (schema, _source_map) =
1244            document_to_schema(&doc).expect("Schema conversion should succeed");
1245
1246        let root_content = &schema.node(schema.root).content;
1247        match root_content {
1248            SchemaNodeContent::Literal(doc) => match &doc.root().content {
1249                NodeValue::Primitive(PrimitiveValue::Text(t)) => {
1250                    assert_eq!(t.as_str(), "any", "Literal should contain 'any'");
1251                }
1252                _ => panic!("Expected Literal with Text primitive, got {:?}", doc),
1253            },
1254            SchemaNodeContent::Any => {
1255                panic!(
1256                    "BUG: Got Any instead of Literal - $variant extension not respected for primitive"
1257                );
1258            }
1259            other => panic!("Expected Literal, got {:?}", other),
1260        }
1261    }
1262
1263    #[test]
1264    fn literal_variant_rejects_partial_map() {
1265        let mut doc = EureDocument::new();
1266        let root_id = doc.get_root_id();
1267
1268        let value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Integer(1.into())));
1269        let mut map = eure_document::map::PartialNodeMap::new();
1270        map.push(
1271            eure_document::value::PartialObjectKey::Hole(Some("x".parse().unwrap())),
1272            value_id,
1273        );
1274        doc.node_mut(root_id).content = NodeValue::PartialMap(map);
1275
1276        let variant_value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1277            Text::plaintext("literal"),
1278        )));
1279        doc.node_mut(root_id)
1280            .extensions
1281            .insert("variant".parse().unwrap(), variant_value_id);
1282
1283        assert_eq!(
1284            Converter::new(&doc).node_to_document(root_id).unwrap_err(),
1285            ConversionError::UnsupportedLiteralValue {
1286                node_id: root_id,
1287                kind: ValueKind::PartialMap,
1288            }
1289        );
1290    }
1291
1292    #[test]
1293    fn union_with_literal_any_variant() {
1294        // Test a union like $types.type which has variants including:
1295        // @variants.any = { = `any`, $variant => "literal" }
1296        // @variants.literal = `any`
1297        // The 'any' variant should match only literal "any", not any value.
1298        let mut doc = EureDocument::new();
1299        let root_id = doc.get_root_id();
1300
1301        // Create the 'any' variant value: { = `any`, $variant => "literal" }
1302        // Note: { = value, $ext => ... } unwraps to just the value with extensions
1303        let any_variant_node = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1304            Text::inline_implicit("any"),
1305        )));
1306        // Add $variant => "literal" extension
1307        let literal_ext = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1308            Text::plaintext("literal"),
1309        )));
1310        doc.node_mut(any_variant_node)
1311            .extensions
1312            .insert("variant".parse().unwrap(), literal_ext);
1313
1314        // Create the 'literal' variant value: `any` (type Any)
1315        let literal_variant_node = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1316            Text::inline_implicit("any"),
1317        )));
1318
1319        // Create the variants map
1320        let mut variants_map = NodeMap::default();
1321        variants_map.insert(ObjectKey::String("any".to_string()), any_variant_node);
1322        variants_map.insert(
1323            ObjectKey::String("literal".to_string()),
1324            literal_variant_node,
1325        );
1326        let variants_node = doc.create_node(NodeValue::Map(variants_map));
1327
1328        // Create root as union
1329        let union_ext = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1330            Text::plaintext("union"),
1331        )));
1332        let untagged_ext = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1333            Text::plaintext("untagged"),
1334        )));
1335        let mut interop_map = NodeMap::default();
1336        interop_map.insert(ObjectKey::String("variant-repr".to_string()), untagged_ext);
1337        let interop_ext = doc.create_node(NodeValue::Map(interop_map));
1338
1339        // Create root map with variants
1340        let mut root_map = NodeMap::default();
1341        root_map.insert(ObjectKey::String("variants".to_string()), variants_node);
1342
1343        doc.node_mut(root_id).content = NodeValue::Map(root_map);
1344        doc.node_mut(root_id)
1345            .extensions
1346            .insert("variant".parse().unwrap(), union_ext);
1347        doc.node_mut(root_id)
1348            .extensions
1349            .insert("interop".parse().unwrap(), interop_ext);
1350
1351        let (schema, _source_map) =
1352            document_to_schema(&doc).expect("Schema conversion should succeed");
1353
1354        // Check the union schema
1355        let root_content = &schema.node(schema.root).content;
1356        match root_content {
1357            SchemaNodeContent::Union(union_schema) => {
1358                // Check 'any' variant is Literal("any"), not Any
1359                let any_variant_id = union_schema
1360                    .variants
1361                    .get("any")
1362                    .expect("'any' variant missing");
1363                let any_content = &schema.node(*any_variant_id).content;
1364                match any_content {
1365                    SchemaNodeContent::Literal(doc) => match &doc.root().content {
1366                        NodeValue::Primitive(PrimitiveValue::Text(t)) => {
1367                            assert_eq!(
1368                                t.as_str(),
1369                                "any",
1370                                "'any' variant should be Literal(\"any\")"
1371                            );
1372                        }
1373                        _ => panic!("'any' variant: expected Text, got {:?}", doc),
1374                    },
1375                    SchemaNodeContent::Any => {
1376                        panic!(
1377                            "BUG: 'any' variant is Any instead of Literal(\"any\") - $variant extension not detected!"
1378                        );
1379                    }
1380                    other => panic!("'any' variant: expected Literal, got {:?}", other),
1381                }
1382
1383                // Check 'literal' variant is Any
1384                let literal_variant_id = union_schema
1385                    .variants
1386                    .get("literal")
1387                    .expect("'literal' variant missing");
1388                let literal_content = &schema.node(*literal_variant_id).content;
1389                match literal_content {
1390                    SchemaNodeContent::Any => {
1391                        // Correct: 'literal' variant should be Any
1392                    }
1393                    other => panic!("'literal' variant: expected Any, got {:?}", other),
1394                }
1395            }
1396            other => panic!("Expected Union, got {:?}", other),
1397        }
1398    }
1399
1400    #[test]
1401    fn extracts_layout_style_rules_from_binding_style_extensions() {
1402        let mut doc = EureDocument::new();
1403        let root_id = doc.get_root_id();
1404        doc.node_mut(root_id).content = NodeValue::empty_map();
1405
1406        let item_id = doc
1407            .add_map_child(ObjectKey::String("item".to_string()), root_id)
1408            .expect("insert item")
1409            .node_id;
1410        doc.node_mut(item_id).content =
1411            NodeValue::Primitive(PrimitiveValue::Text(Text::inline_implicit("integer")));
1412
1413        let style_id = doc
1414            .add_extension("binding-style".parse().unwrap(), item_id)
1415            .expect("insert binding-style")
1416            .node_id;
1417        doc.node_mut(style_id).content =
1418            NodeValue::Primitive(PrimitiveValue::Text(Text::plaintext("section-binding")));
1419
1420        let (_schema, layout, _source_map) =
1421            document_to_schema_with_layout(&doc).expect("conversion succeeds");
1422
1423        let expected_path = EurePath(vec![PathSegment::Value(ObjectKey::String(
1424            "item".to_string(),
1425        ))]);
1426        let style = layout.by_path.get(&expected_path).expect("style for item");
1427        assert_eq!(*style, eure_document::layout::LayoutStyle::SectionBinding);
1428    }
1429
1430    #[test]
1431    fn preserves_record_property_order_in_layout_rules() {
1432        let doc = eure!({
1433            b = @code("integer")
1434            a = @code("integer")
1435        });
1436
1437        let (_schema, layout, _source_map) =
1438            document_to_schema_with_layout(&doc).expect("conversion succeeds");
1439
1440        let expected = vec![
1441            PathSegment::Value(ObjectKey::String("b".to_string())),
1442            PathSegment::Value(ObjectKey::String("a".to_string())),
1443        ];
1444        let root_order = layout
1445            .order_by_path
1446            .get(&EurePath::root())
1447            .expect("root order rule");
1448        assert_eq!(*root_order, expected);
1449    }
1450
1451    #[test]
1452    fn preserves_type_codegen_on_non_record_non_union_type_nodes() {
1453        let mut doc = EureDocument::new();
1454        let root_id = doc.get_root_id();
1455
1456        let type_node = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1457            Text::inline_implicit("text"),
1458        )));
1459        let type_name_node = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1460            Text::plaintext("BadTypeName"),
1461        )));
1462
1463        let mut codegen_map = NodeMap::default();
1464        codegen_map.insert(ObjectKey::String("type".to_string()), type_name_node);
1465        let codegen_node = doc.create_node(NodeValue::Map(codegen_map));
1466        doc.node_mut(type_node)
1467            .extensions
1468            .insert("codegen".parse().unwrap(), codegen_node);
1469
1470        let mut types_map = NodeMap::default();
1471        types_map.insert(ObjectKey::String("bad".to_string()), type_node);
1472        let types_node = doc.create_node(NodeValue::Map(types_map));
1473        doc.node_mut(root_id)
1474            .extensions
1475            .insert("types".parse().unwrap(), types_node);
1476        doc.node_mut(root_id).content =
1477            NodeValue::Primitive(PrimitiveValue::Text(Text::inline_implicit("text")));
1478
1479        let (schema, _source_map) = document_to_schema(&doc)
1480            .expect("type-level $codegen on non-union type nodes should be preserved");
1481
1482        let bad_ident: Identifier = "bad".parse().unwrap();
1483        let bad_type_id = schema.types.get(&bad_ident).expect("type `bad`");
1484        let type_node = schema.node(*bad_type_id);
1485        let TypeCodegen::Record(record) = &type_node.type_codegen else {
1486            panic!("expected non-union type codegen to use Record variant");
1487        };
1488        assert_eq!(record.type_name.as_deref(), Some("BadTypeName"));
1489    }
1490
1491    #[test]
1492    fn rejects_non_productive_reference_cycles() {
1493        let doc = eure!({
1494            %types.a = @code("$types.b")
1495            %types.b = @code("$types.a")
1496            data = @code("$types.a")
1497        });
1498
1499        let err = document_to_schema(&doc).expect_err("cycle must be rejected");
1500        match err {
1501            ConversionError::NonProductiveReferenceCycle(path) => {
1502                assert!(path.contains("$types.a"));
1503                assert!(path.contains("$types.b"));
1504            }
1505            other => panic!("expected NonProductiveReferenceCycle, got {:?}", other),
1506        }
1507    }
1508}