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, ParsedExports, ParsedExtTypeSchema, ParsedFloatSchema, ParsedImports,
47    ParsedIntegerSchema, ParsedMapSchema, ParsedRecordSchema, ParsedSchemaMetadata,
48    ParsedSchemaNode, ParsedSchemaNodeContent, ParsedTupleSchema, ParsedUnionSchema,
49    ParsedUnknownFieldsPolicy, parse_root_exports, parse_root_imports,
50};
51use crate::resolver::{LoadedSchemaSet, ResolvedSchemaUri, ResolverError};
52use crate::type_path_trace::LayoutStrategies;
53use crate::{
54    ArraySchema, Bound, CodegenDefaults, ExtTypeSchema, FloatPrecision, FloatSchema, IntegerSchema,
55    MapSchema, RecordCodegen, RecordFieldSchema, RecordSchema, RootCodegen, SchemaDocument,
56    SchemaImport, SchemaMetadata, SchemaNodeContent, SchemaNodeId, TupleSchema, TypeCodegen,
57    TypeReference, UnionCodegen, UnionSchema, UnknownFieldsPolicy,
58};
59use eure_document::document::node::{Node, NodeValue};
60use eure_document::document::{EureDocument, InsertErrorKind, NodeId};
61use eure_document::identifier::Identifier;
62use eure_document::parse::ParseError;
63use eure_document::path::{ArrayIndexKind, EurePath, PathSegment};
64use eure_document::value::{ObjectKey, ValueKind};
65use indexmap::{IndexMap, IndexSet};
66use num_bigint::BigInt;
67use thiserror::Error;
68
69/// Errors that can occur during document to schema conversion
70#[derive(Debug, Error, Clone, PartialEq)]
71pub enum ConversionError {
72    #[error("Invalid type name: {0}")]
73    InvalidTypeName(ObjectKey),
74
75    #[error("unsupported literal value at node {node_id:?}: {kind}")]
76    UnsupportedLiteralValue { node_id: NodeId, kind: ValueKind },
77
78    #[error("document insert error while copying literal value: {0}")]
79    DocumentInsert(#[from] InsertErrorKind),
80
81    #[error("Invalid extension value: {extension} at path {path}")]
82    InvalidExtensionValue { extension: String, path: String },
83
84    #[error("Invalid range string: {0}")]
85    InvalidRangeString(String),
86
87    #[error("Invalid precision: {0} (expected \"f32\" or \"f64\")")]
88    InvalidPrecision(String),
89
90    #[error("Undefined type reference: {0}")]
91    UndefinedTypeReference(String),
92
93    #[error("non-productive reference cycle detected: {0}")]
94    NonProductiveReferenceCycle(String),
95
96    #[error(
97        "invalid `$codegen` extension at node {node_id:?}: supported only for record/union, got {schema_kind}"
98    )]
99    InvalidTypeCodegenTarget {
100        node_id: NodeId,
101        schema_kind: String,
102    },
103
104    #[error("failed to resolve schema import `{alias}` -> \"{raw_path}\": {source}")]
105    ImportResolverFailed {
106        alias: String,
107        raw_path: String,
108        #[source]
109        source: ResolverError,
110    },
111
112    #[error("schema import cycle: {}", format_cycle(.cycle, .attempted))]
113    ImportCycle {
114        cycle: Vec<ResolvedSchemaUri>,
115        attempted: ResolvedSchemaUri,
116    },
117
118    #[error(
119        "type reference uses unknown import namespace `{namespace}` (in `$types.{namespace}.{name}`)"
120    )]
121    UnknownImportNamespace { namespace: String, name: String },
122
123    #[error("type `{name}` is not exported by `{namespace}` (declared in $import)")]
124    TypeNotExported { namespace: String, name: String },
125
126    #[error("$export lists `{name}`, but no such locally-declared type exists")]
127    ExportedNameNotDeclared { name: String },
128
129    #[error("schema import `{alias}` -> \"{raw_path}\" was not loaded for {base}")]
130    ImportNotLoaded {
131        base: ResolvedSchemaUri,
132        alias: String,
133        raw_path: String,
134    },
135
136    #[error("Parse error: {0}")]
137    ParseError(#[from] ParseError),
138}
139
140fn format_cycle(cycle: &[ResolvedSchemaUri], attempted: &ResolvedSchemaUri) -> String {
141    let mut parts: Vec<String> = cycle.iter().map(|u| u.to_string()).collect();
142    parts.push(attempted.to_string());
143    parts.join(" -> ")
144}
145
146/// Source document location for a schema node.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct SchemaSource {
149    pub uri: ResolvedSchemaUri,
150    pub node_id: NodeId,
151}
152
153/// Mapping from schema node IDs to their source document node IDs.
154/// Used for propagating origin information for error formatting.
155pub type SchemaSourceMap = IndexMap<SchemaNodeId, SchemaSource>;
156
157/// Internal converter state
158struct Converter<'a> {
159    doc: &'a EureDocument,
160    loaded: &'a LoadedSchemaSet,
161    schema: SchemaDocument,
162    /// Track source document NodeId for each schema node
163    source_map: SchemaSourceMap,
164    /// Canonical identity of *this* document (the importer). Used as the base
165    /// against which `$import` paths resolve, and for cycle detection.
166    base_uri: ResolvedSchemaUri,
167}
168
169impl<'a> Converter<'a> {
170    fn new(
171        doc: &'a EureDocument,
172        loaded: &'a LoadedSchemaSet,
173        base_uri: ResolvedSchemaUri,
174    ) -> Self {
175        Self {
176            doc,
177            loaded,
178            schema: SchemaDocument::new(),
179            source_map: IndexMap::new(),
180            base_uri,
181        }
182    }
183
184    /// Convert the root node and produce the final schema with source mapping.
185    ///
186    /// `visited` is the set of schema URIs already on the conversion stack; it
187    /// is used to detect import cycles. The caller is responsible for inserting
188    /// `self.base_uri` into the set before calling and removing it afterwards.
189    fn run(
190        mut self,
191        visited: &mut IndexSet<ResolvedSchemaUri>,
192    ) -> Result<(SchemaDocument, SchemaSourceMap), ConversionError> {
193        let root_id = self.doc.get_root_id();
194
195        // 1. Inline already-loaded imports first so their nodes are available
196        //    before local definitions reference them.
197        self.process_imports(visited)?;
198
199        // 2. Convert local `$types` definitions and root codegen.
200        let root_node = self.doc.node(root_id);
201        self.convert_types(root_node)?;
202        self.convert_root_codegen(root_node)?;
203
204        // 3. Convert the root node itself.
205        self.schema.root = self.convert_node_allow_non_type_codegen(root_id)?;
206
207        // 4. Compute the export set from `$export` (or default to all locally-declared).
208        self.compute_exports()?;
209
210        // 5. Validate type references and resolve them to schema node IDs.
211        self.validate_and_rewrite_type_references()?;
212        self.validate_non_productive_reference_cycles()?;
213
214        Ok((self.schema, self.source_map))
215    }
216
217    /// Inline every already-loaded `$import` entry into `self.schema`.
218    fn process_imports(
219        &mut self,
220        visited: &mut IndexSet<ResolvedSchemaUri>,
221    ) -> Result<(), ConversionError> {
222        let root_id = self.doc.get_root_id();
223        let root_ctx = self.doc.parse_context(root_id);
224        let parsed: ParsedImports = parse_root_imports(&root_ctx)?;
225
226        for (alias, entry) in parsed.entries {
227            let imported_uri = self
228                .loaded
229                .import_target(&self.base_uri, &alias)
230                .cloned()
231                .ok_or_else(|| ConversionError::ImportNotLoaded {
232                    base: self.base_uri.clone(),
233                    alias: alias.to_string(),
234                    raw_path: entry.raw_path.clone(),
235                })?;
236            let child_doc = self.loaded.document(&imported_uri).ok_or_else(|| {
237                ConversionError::ImportNotLoaded {
238                    base: self.base_uri.clone(),
239                    alias: alias.to_string(),
240                    raw_path: entry.raw_path.clone(),
241                }
242            })?;
243            let (child_schema, child_source_map) =
244                convert_with_visited(self.loaded, imported_uri.clone(), child_doc, visited)?;
245
246            self.inline_imported_schema(&alias, imported_uri, child_schema, child_source_map)?;
247        }
248        Ok(())
249    }
250
251    /// Deep-copy `child` into `self.schema` and register its types under
252    /// `self.schema.imports[alias]` for later reference resolution.
253    fn inline_imported_schema(
254        &mut self,
255        alias: &Identifier,
256        child_uri: ResolvedSchemaUri,
257        child: SchemaDocument,
258        child_source_map: SchemaSourceMap,
259    ) -> Result<(), ConversionError> {
260        // Reserve placeholder nodes in the parent arena, one per child node.
261        let mut remap: Vec<SchemaNodeId> = Vec::with_capacity(child.nodes.len());
262        for _ in 0..child.nodes.len() {
263            let new_id = self.schema.create_node(SchemaNodeContent::Any);
264            remap.push(new_id);
265        }
266
267        // Copy each child node, remapping SchemaNodeIds.
268        for (i, child_node) in child.nodes.iter().enumerate() {
269            let parent_id = remap[i];
270            let new_content = remap_node_content(&child_node.content, &remap);
271            let new_ext_types = remap_ext_types(&child_node.ext_types, &remap);
272
273            let parent_node = self.schema.node_mut(parent_id);
274            parent_node.content = new_content;
275            parent_node.metadata = child_node.metadata.clone();
276            parent_node.ext_types = new_ext_types;
277            parent_node.type_codegen = child_node.type_codegen.clone();
278        }
279
280        // Register only the child's local type namespace under this import.
281        let mut all_types = IndexMap::new();
282        for (name, child_id) in &child.types {
283            all_types.insert(name.clone(), remap[child_id.0]);
284        }
285
286        for (child_id, source) in child_source_map {
287            self.source_map.insert(remap[child_id.0], source);
288        }
289
290        self.schema.imports.insert(
291            alias.clone(),
292            SchemaImport {
293                uri: child_uri,
294                all_types,
295                exports: child.exports.clone(),
296            },
297        );
298        Ok(())
299    }
300
301    /// Compute `self.schema.exports` from the optional root-level `$export`.
302    /// Defaults to "all locally-declared types".
303    fn compute_exports(&mut self) -> Result<(), ConversionError> {
304        let root_ctx = self.doc.parse_context(self.doc.get_root_id());
305        let parsed = parse_root_exports(&root_ctx)?;
306
307        match parsed {
308            None => {
309                // No `$export` — every locally-declared type is public.
310                self.schema.exports = self.schema.types.keys().cloned().collect();
311            }
312            Some(ParsedExports::Explicit { names }) => {
313                let mut exports = IndexSet::new();
314                for name in names {
315                    if !self.schema.types.contains_key(&name) {
316                        return Err(ConversionError::ExportedNameNotDeclared {
317                            name: name.to_string(),
318                        });
319                    }
320                    exports.insert(name);
321                }
322                self.schema.exports = exports;
323            }
324        }
325        Ok(())
326    }
327
328    /// Validate that every `Reference` resolves and rewrite named refs to
329    /// arena-local schema node IDs. Runs after imports have been inlined and
330    /// local types registered.
331    fn validate_and_rewrite_type_references(&mut self) -> Result<(), ConversionError> {
332        for index in 0..self.schema.nodes.len() {
333            // Only Reference nodes need rewriting.
334            let needs_rewrite = matches!(
335                &self.schema.nodes[index].content,
336                SchemaNodeContent::Reference(_)
337            );
338            if !needs_rewrite {
339                continue;
340            }
341
342            // Take the existing content out so we can transform it without
343            // holding a long-lived borrow of `self.schema`.
344            let placeholder = SchemaNodeContent::Any;
345            let original = std::mem::replace(&mut self.schema.nodes[index].content, placeholder);
346            let SchemaNodeContent::Reference(type_ref) = original else {
347                unreachable!("checked above");
348            };
349            let resolved = self.resolve_type_reference(type_ref)?;
350            self.schema.nodes[index].content = SchemaNodeContent::Reference(resolved);
351        }
352        Ok(())
353    }
354
355    fn resolve_type_reference(
356        &self,
357        type_ref: TypeReference,
358    ) -> Result<TypeReference, ConversionError> {
359        match type_ref {
360            TypeReference::Resolved(_) => Ok(type_ref),
361            TypeReference::Named {
362                namespace: None,
363                name,
364            } => {
365                let target = self
366                    .schema
367                    .types
368                    .get(&name)
369                    .copied()
370                    .ok_or_else(|| ConversionError::UndefinedTypeReference(name.to_string()))?;
371                Ok(TypeReference::Resolved(target))
372            }
373            TypeReference::Named {
374                namespace: Some(ns),
375                name,
376            } => {
377                let import = self.schema.imports.get(&ns).ok_or_else(|| {
378                    ConversionError::UnknownImportNamespace {
379                        namespace: ns.to_string(),
380                        name: name.to_string(),
381                    }
382                })?;
383                if !import.exports.contains(&name) {
384                    return Err(ConversionError::TypeNotExported {
385                        namespace: ns.to_string(),
386                        name: name.to_string(),
387                    });
388                }
389                let target = import.all_types.get(&name).copied().ok_or_else(|| {
390                    ConversionError::UndefinedTypeReference(format!("{}.{}", ns, name))
391                })?;
392                Ok(TypeReference::Resolved(target))
393            }
394        }
395    }
396
397    fn convert_root_codegen(&mut self, node: &Node) -> Result<(), ConversionError> {
398        let codegen_ident: Identifier = "codegen".parse().unwrap();
399        let codegen_defaults_ident: Identifier = "codegen-defaults".parse().unwrap();
400
401        if let Some(node_id) = node.extensions.get(&codegen_ident) {
402            let rec = self.doc.parse_record(*node_id)?;
403            self.schema.root_codegen = RootCodegen {
404                type_name: rec.parse_field_optional::<String>("type")?,
405            };
406        }
407
408        if let Some(node_id) = node.extensions.get(&codegen_defaults_ident) {
409            self.schema.codegen_defaults = self.doc.parse::<CodegenDefaults>(*node_id)?;
410        }
411
412        Ok(())
413    }
414
415    /// Convert all local type definitions from $types extension.
416    fn convert_types(&mut self, node: &Node) -> Result<(), ConversionError> {
417        let types_ident: Identifier = "types".parse().unwrap();
418        if let Some(types_node_id) = node.extensions.get(&types_ident) {
419            let types_node = self.doc.node(*types_node_id);
420            if let NodeValue::Map(map) = &types_node.content {
421                for (key, &node_id) in map.iter() {
422                    if let ObjectKey::String(name) = key {
423                        let type_name: Identifier = name
424                            .parse()
425                            .map_err(|_| ConversionError::InvalidTypeName(key.clone()))?;
426                        let schema_id = self.convert_node(node_id)?;
427                        self.schema.types.insert(type_name, schema_id);
428                    } else {
429                        return Err(ConversionError::InvalidTypeName(key.clone()));
430                    }
431                }
432            } else {
433                return Err(ConversionError::InvalidExtensionValue {
434                    extension: "types".to_string(),
435                    path: "$types must be a map".to_string(),
436                });
437            }
438        }
439        Ok(())
440    }
441
442    fn validate_non_productive_reference_cycles(&self) -> Result<(), ConversionError> {
443        #[derive(Clone, Copy, PartialEq, Eq)]
444        enum Mark {
445            Visiting,
446            Done,
447        }
448
449        fn next_ref_target(schema: &SchemaDocument, node_id: SchemaNodeId) -> Option<SchemaNodeId> {
450            let node = schema.node(node_id);
451            let SchemaNodeContent::Reference(type_ref) = &node.content else {
452                return None;
453            };
454            schema.resolve_reference(type_ref)
455        }
456
457        fn display_node(schema: &SchemaDocument, id: SchemaNodeId) -> String {
458            let reference = TypeReference::Resolved(id);
459            if let Some(name) = schema.reference_name(&reference) {
460                format!("$types.{}", name)
461            } else {
462                format!("node#{}", id.0)
463            }
464        }
465
466        fn visit(
467            schema: &SchemaDocument,
468            node_id: SchemaNodeId,
469            marks: &mut IndexMap<SchemaNodeId, Mark>,
470            stack: &mut Vec<SchemaNodeId>,
471        ) -> Result<(), ConversionError> {
472            if matches!(marks.get(&node_id), Some(Mark::Done)) {
473                return Ok(());
474            }
475            if matches!(marks.get(&node_id), Some(Mark::Visiting)) {
476                let start = stack.iter().position(|sid| *sid == node_id).unwrap_or(0);
477                let mut cycle: Vec<String> = stack[start..]
478                    .iter()
479                    .map(|sid| display_node(schema, *sid))
480                    .collect();
481                cycle.push(display_node(schema, node_id));
482                return Err(ConversionError::NonProductiveReferenceCycle(
483                    cycle.join(" -> "),
484                ));
485            }
486
487            marks.insert(node_id, Mark::Visiting);
488            stack.push(node_id);
489            if let Some(next_id) = next_ref_target(schema, node_id) {
490                visit(schema, next_id, marks, stack)?;
491            }
492            stack.pop();
493            marks.insert(node_id, Mark::Done);
494            Ok(())
495        }
496
497        let mut marks = IndexMap::new();
498        let mut stack = Vec::new();
499        for index in 0..self.schema.nodes.len() {
500            visit(&self.schema, SchemaNodeId(index), &mut marks, &mut stack)?;
501        }
502        Ok(())
503    }
504
505    /// Convert a document node to a schema node using FromEure trait
506    fn convert_node(&mut self, node_id: NodeId) -> Result<SchemaNodeId, ConversionError> {
507        self.convert_node_inner(node_id, false)
508    }
509
510    fn convert_node_allow_non_type_codegen(
511        &mut self,
512        node_id: NodeId,
513    ) -> Result<SchemaNodeId, ConversionError> {
514        self.convert_node_inner(node_id, true)
515    }
516
517    fn convert_node_inner(
518        &mut self,
519        node_id: NodeId,
520        allow_non_type_codegen: bool,
521    ) -> Result<SchemaNodeId, ConversionError> {
522        // Parse the node using FromEure trait
523        let parsed: ParsedSchemaNode = self.doc.parse(node_id)?;
524        let ParsedSchemaNode {
525            content: parsed_content,
526            metadata: parsed_metadata,
527            ext_types: parsed_ext_types,
528            codegen: parsed_codegen,
529        } = parsed;
530
531        // Convert the parsed node to final schema
532        let content = self.convert_content(parsed_content)?;
533        let metadata = self.convert_metadata(parsed_metadata)?;
534        let ext_types = self.convert_ext_types(parsed_ext_types)?;
535        let type_codegen =
536            self.convert_type_codegen(parsed_codegen, &content, allow_non_type_codegen)?;
537
538        // Create the final schema node
539        let schema_id = self.schema.create_node(content);
540        let schema_node = self.schema.node_mut(schema_id);
541        schema_node.metadata = metadata;
542        schema_node.ext_types = ext_types;
543        schema_node.type_codegen = type_codegen;
544
545        // Record source mapping for span resolution
546        self.source_map.insert(
547            schema_id,
548            SchemaSource {
549                uri: self.base_uri.clone(),
550                node_id,
551            },
552        );
553        Ok(schema_id)
554    }
555
556    fn convert_type_codegen(
557        &self,
558        codegen_node_id: Option<NodeId>,
559        content: &SchemaNodeContent,
560        allow_non_type_codegen: bool,
561    ) -> Result<TypeCodegen, ConversionError> {
562        let Some(codegen_node_id) = codegen_node_id else {
563            return Ok(TypeCodegen::None);
564        };
565
566        if allow_non_type_codegen
567            && !matches!(
568                content,
569                SchemaNodeContent::Record(_) | SchemaNodeContent::Union(_)
570            )
571        {
572            return Ok(TypeCodegen::None);
573        }
574
575        match content {
576            SchemaNodeContent::Union(_) => Ok(TypeCodegen::Union(
577                self.doc.parse::<UnionCodegen>(codegen_node_id)?,
578            )),
579            _ => Ok(TypeCodegen::Record(
580                self.doc.parse::<RecordCodegen>(codegen_node_id)?,
581            )),
582        }
583    }
584
585    /// Convert parsed schema node content to final schema node content
586    fn convert_content(
587        &mut self,
588        content: ParsedSchemaNodeContent,
589    ) -> Result<SchemaNodeContent, ConversionError> {
590        match content {
591            ParsedSchemaNodeContent::Any => Ok(SchemaNodeContent::Any),
592            ParsedSchemaNodeContent::Boolean => Ok(SchemaNodeContent::Boolean),
593            ParsedSchemaNodeContent::Null => Ok(SchemaNodeContent::Null),
594            ParsedSchemaNodeContent::Text(schema) => Ok(SchemaNodeContent::Text(schema)),
595            ParsedSchemaNodeContent::Reference(type_ref) => {
596                Ok(SchemaNodeContent::Reference(type_ref))
597            }
598
599            ParsedSchemaNodeContent::Integer(parsed) => Ok(SchemaNodeContent::Integer(
600                self.convert_integer_schema(parsed)?,
601            )),
602            ParsedSchemaNodeContent::Float(parsed) => {
603                Ok(SchemaNodeContent::Float(self.convert_float_schema(parsed)?))
604            }
605            ParsedSchemaNodeContent::Literal(node_id) => {
606                Ok(SchemaNodeContent::Literal(self.node_to_document(node_id)?))
607            }
608            ParsedSchemaNodeContent::Array(parsed) => {
609                Ok(SchemaNodeContent::Array(self.convert_array_schema(parsed)?))
610            }
611            ParsedSchemaNodeContent::Map(parsed) => {
612                Ok(SchemaNodeContent::Map(self.convert_map_schema(parsed)?))
613            }
614            ParsedSchemaNodeContent::Record(parsed) => Ok(SchemaNodeContent::Record(
615                self.convert_record_schema(parsed)?,
616            )),
617            ParsedSchemaNodeContent::Tuple(parsed) => {
618                Ok(SchemaNodeContent::Tuple(self.convert_tuple_schema(parsed)?))
619            }
620            ParsedSchemaNodeContent::Union(parsed) => {
621                Ok(SchemaNodeContent::Union(self.convert_union_schema(parsed)?))
622            }
623        }
624    }
625
626    /// Convert parsed integer schema (with range string) to final integer schema (with Bound)
627    fn convert_integer_schema(
628        &self,
629        parsed: ParsedIntegerSchema,
630    ) -> Result<IntegerSchema, ConversionError> {
631        let (min, max) = if let Some(range_str) = &parsed.range {
632            parse_integer_range(range_str)?
633        } else {
634            (Bound::Unbounded, Bound::Unbounded)
635        };
636
637        Ok(IntegerSchema {
638            min,
639            max,
640            multiple_of: parsed.multiple_of,
641        })
642    }
643
644    /// Convert parsed float schema (with range string) to final float schema (with Bound)
645    fn convert_float_schema(
646        &self,
647        parsed: ParsedFloatSchema,
648    ) -> Result<FloatSchema, ConversionError> {
649        let (min, max) = if let Some(range_str) = &parsed.range {
650            parse_float_range(range_str)?
651        } else {
652            (Bound::Unbounded, Bound::Unbounded)
653        };
654
655        let precision = match parsed.precision.as_deref() {
656            Some("f32") => FloatPrecision::F32,
657            Some("f64") | None => FloatPrecision::F64,
658            Some(other) => {
659                return Err(ConversionError::InvalidPrecision(other.to_string()));
660            }
661        };
662
663        Ok(FloatSchema {
664            min,
665            max,
666            multiple_of: parsed.multiple_of,
667            precision,
668        })
669    }
670
671    /// Convert parsed array schema to final array schema
672    fn convert_array_schema(
673        &mut self,
674        parsed: ParsedArraySchema,
675    ) -> Result<ArraySchema, ConversionError> {
676        let item = self.convert_node(parsed.item)?;
677        let contains = parsed
678            .contains
679            .map(|id| self.convert_node(id))
680            .transpose()?;
681
682        Ok(ArraySchema {
683            item,
684            min_length: parsed.min_length,
685            max_length: parsed.max_length,
686            unique: parsed.unique,
687            contains,
688            binding_style: parsed.binding_style,
689        })
690    }
691
692    /// Convert parsed map schema to final map schema
693    fn convert_map_schema(
694        &mut self,
695        parsed: ParsedMapSchema,
696    ) -> Result<MapSchema, ConversionError> {
697        let key = self.convert_node(parsed.key)?;
698        let value = self.convert_node(parsed.value)?;
699
700        Ok(MapSchema {
701            key,
702            value,
703            min_size: parsed.min_size,
704            max_size: parsed.max_size,
705        })
706    }
707
708    /// Convert parsed tuple schema to final tuple schema
709    fn convert_tuple_schema(
710        &mut self,
711        parsed: ParsedTupleSchema,
712    ) -> Result<TupleSchema, ConversionError> {
713        let elements: Vec<SchemaNodeId> = parsed
714            .elements
715            .iter()
716            .map(|&id| self.convert_node(id))
717            .collect::<Result<_, _>>()?;
718
719        Ok(TupleSchema {
720            elements,
721            binding_style: parsed.binding_style,
722        })
723    }
724
725    /// Convert parsed record schema to final record schema
726    fn convert_record_schema(
727        &mut self,
728        parsed: ParsedRecordSchema,
729    ) -> Result<RecordSchema, ConversionError> {
730        let mut properties = IndexMap::new();
731
732        for (field_name, field_parsed) in parsed.properties {
733            let schema = self.convert_node_allow_non_type_codegen(field_parsed.schema)?;
734            properties.insert(
735                field_name,
736                RecordFieldSchema {
737                    schema,
738                    optional: field_parsed.optional,
739                    binding_style: field_parsed.binding_style,
740                    field_codegen: field_parsed.codegen.unwrap_or_default(),
741                },
742            );
743        }
744
745        // Convert flatten targets
746        let flatten = parsed
747            .flatten
748            .into_iter()
749            .map(|id| self.convert_node(id))
750            .collect::<Result<Vec<_>, _>>()?;
751
752        let unknown_fields = self.convert_unknown_fields_policy(parsed.unknown_fields)?;
753
754        Ok(RecordSchema {
755            properties,
756            flatten,
757            unknown_fields,
758        })
759    }
760
761    /// Convert parsed union schema to final union schema
762    fn convert_union_schema(
763        &mut self,
764        parsed: ParsedUnionSchema,
765    ) -> Result<UnionSchema, ConversionError> {
766        let ParsedUnionSchema {
767            variants: parsed_variants,
768            unambiguous,
769            interop,
770            deny_untagged,
771        } = parsed;
772        let mut variants = IndexMap::new();
773
774        for (variant_name, variant_node_id) in parsed_variants {
775            let schema = self.convert_node(variant_node_id)?;
776            variants.insert(variant_name, schema);
777        }
778
779        Ok(UnionSchema {
780            variants,
781            unambiguous,
782            interop,
783            deny_untagged,
784        })
785    }
786
787    /// Convert parsed unknown fields policy to final policy
788    fn convert_unknown_fields_policy(
789        &mut self,
790        parsed: ParsedUnknownFieldsPolicy,
791    ) -> Result<UnknownFieldsPolicy, ConversionError> {
792        match parsed {
793            ParsedUnknownFieldsPolicy::Deny => Ok(UnknownFieldsPolicy::Deny),
794            ParsedUnknownFieldsPolicy::Allow => Ok(UnknownFieldsPolicy::Allow),
795            ParsedUnknownFieldsPolicy::Schema(node_id) => {
796                let schema = self.convert_node(node_id)?;
797                Ok(UnknownFieldsPolicy::Schema(schema))
798            }
799        }
800    }
801
802    /// Convert parsed metadata to final metadata
803    fn convert_metadata(
804        &mut self,
805        parsed: ParsedSchemaMetadata,
806    ) -> Result<SchemaMetadata, ConversionError> {
807        let default = parsed
808            .default
809            .map(|id| self.node_to_document(id))
810            .transpose()?;
811
812        let examples = parsed
813            .examples
814            .map(|ids| {
815                ids.into_iter()
816                    .map(|id| self.node_to_document(id))
817                    .collect::<Result<Vec<_>, _>>()
818            })
819            .transpose()?;
820
821        Ok(SchemaMetadata {
822            description: parsed.description,
823            deprecated: parsed.deprecated,
824            default,
825            examples,
826        })
827    }
828
829    /// Convert parsed ext types to final ext types
830    fn convert_ext_types(
831        &mut self,
832        parsed: IndexMap<Identifier, ParsedExtTypeSchema>,
833    ) -> Result<IndexMap<Identifier, ExtTypeSchema>, ConversionError> {
834        let mut result = IndexMap::new();
835
836        for (name, parsed_schema) in parsed {
837            let schema = self.convert_node(parsed_schema.schema)?;
838            result.insert(
839                name,
840                ExtTypeSchema {
841                    schema,
842                    optional: parsed_schema.optional,
843                    binding_style: parsed_schema.binding_style,
844                },
845            );
846        }
847
848        Ok(result)
849    }
850
851    /// Extract a subtree as a new EureDocument for literal types
852    fn node_to_document(&self, node_id: NodeId) -> Result<EureDocument, ConversionError> {
853        let mut new_doc = EureDocument::new();
854        let root_id = new_doc.get_root_id();
855        self.copy_node_to(&mut new_doc, root_id, node_id)?;
856        Ok(new_doc)
857    }
858
859    /// Recursively copy a node from source document to destination
860    fn copy_node_to(
861        &self,
862        dest: &mut EureDocument,
863        dest_node_id: NodeId,
864        src_node_id: NodeId,
865    ) -> Result<(), ConversionError> {
866        let src_node = self.doc.node(src_node_id);
867
868        // Collect child info before mutating dest
869        let children_to_copy: Vec<_> = match &src_node.content {
870            NodeValue::Primitive(prim) => {
871                dest.set_content(dest_node_id, NodeValue::Primitive(prim.clone()));
872                vec![]
873            }
874            NodeValue::Array(arr) => {
875                dest.set_content(dest_node_id, NodeValue::empty_array());
876                arr.to_vec()
877            }
878            NodeValue::Tuple(tup) => {
879                dest.set_content(dest_node_id, NodeValue::empty_tuple());
880                tup.to_vec()
881            }
882            NodeValue::Map(map) => {
883                dest.set_content(dest_node_id, NodeValue::empty_map());
884                map.iter()
885                    .map(|(k, &v)| (k.clone(), v))
886                    .collect::<Vec<_>>()
887                    .into_iter()
888                    .map(|(_, v)| v)
889                    .collect()
890            }
891            NodeValue::PartialMap(_) => {
892                return Err(ConversionError::UnsupportedLiteralValue {
893                    node_id: src_node_id,
894                    kind: ValueKind::PartialMap,
895                });
896            }
897            NodeValue::Hole(_) => {
898                return Err(ConversionError::UnsupportedLiteralValue {
899                    node_id: src_node_id,
900                    kind: ValueKind::Hole,
901                });
902            }
903        };
904
905        // Skip ALL extensions during literal value copying.
906        // Extensions are schema metadata (like $variant, $deny-untagged, $optional, etc.)
907        // and should not be part of the literal value comparison.
908        // Literal types compare only the data structure, not metadata.
909
910        // Now copy children based on the type
911        let src_node = self.doc.node(src_node_id);
912        match &src_node.content {
913            NodeValue::Array(_) => {
914                for child_id in children_to_copy {
915                    let new_child_id = dest.add_array_element(None, dest_node_id)?.node_id;
916                    self.copy_node_to(dest, new_child_id, child_id)?;
917                }
918            }
919            NodeValue::Tuple(_) => {
920                for (index, child_id) in children_to_copy.into_iter().enumerate() {
921                    let new_child_id = dest.add_tuple_element(index as u8, dest_node_id)?.node_id;
922                    self.copy_node_to(dest, new_child_id, child_id)?;
923                }
924            }
925            NodeValue::Map(map) => {
926                for (key, &child_id) in map.iter() {
927                    let new_child_id = dest.add_map_child(key.clone(), dest_node_id)?.node_id;
928                    self.copy_node_to(dest, new_child_id, child_id)?;
929                }
930            }
931            _ => {}
932        }
933
934        Ok(())
935    }
936}
937
938/// Parse an integer range string (Rust-style or interval notation)
939fn parse_integer_range(s: &str) -> Result<(Bound<BigInt>, Bound<BigInt>), ConversionError> {
940    let s = s.trim();
941
942    // Try interval notation first: [a, b], (a, b), [a, b), (a, b]
943    if s.starts_with('[') || s.starts_with('(') {
944        return parse_interval_integer(s);
945    }
946
947    // Rust-style: a..b, a..=b, a.., ..b, ..=b
948    if let Some(eq_pos) = s.find("..=") {
949        let left = &s[..eq_pos];
950        let right = &s[eq_pos + 3..];
951        let min = if left.is_empty() {
952            Bound::Unbounded
953        } else {
954            Bound::Inclusive(parse_bigint(left)?)
955        };
956        let max = if right.is_empty() {
957            Bound::Unbounded
958        } else {
959            Bound::Inclusive(parse_bigint(right)?)
960        };
961        Ok((min, max))
962    } else if let Some(dot_pos) = s.find("..") {
963        let left = &s[..dot_pos];
964        let right = &s[dot_pos + 2..];
965        let min = if left.is_empty() {
966            Bound::Unbounded
967        } else {
968            Bound::Inclusive(parse_bigint(left)?)
969        };
970        let max = if right.is_empty() {
971            Bound::Unbounded
972        } else {
973            Bound::Exclusive(parse_bigint(right)?)
974        };
975        Ok((min, max))
976    } else {
977        Err(ConversionError::InvalidRangeString(s.to_string()))
978    }
979}
980
981/// Parse interval notation for integers: [a, b], (a, b), etc.
982fn parse_interval_integer(s: &str) -> Result<(Bound<BigInt>, Bound<BigInt>), ConversionError> {
983    let left_inclusive = s.starts_with('[');
984    let right_inclusive = s.ends_with(']');
985
986    let inner = &s[1..s.len() - 1];
987    let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
988    if parts.len() != 2 {
989        return Err(ConversionError::InvalidRangeString(s.to_string()));
990    }
991
992    let min = if parts[0].is_empty() {
993        Bound::Unbounded
994    } else if left_inclusive {
995        Bound::Inclusive(parse_bigint(parts[0])?)
996    } else {
997        Bound::Exclusive(parse_bigint(parts[0])?)
998    };
999
1000    let max = if parts[1].is_empty() {
1001        Bound::Unbounded
1002    } else if right_inclusive {
1003        Bound::Inclusive(parse_bigint(parts[1])?)
1004    } else {
1005        Bound::Exclusive(parse_bigint(parts[1])?)
1006    };
1007
1008    Ok((min, max))
1009}
1010
1011/// Parse a float range string
1012fn parse_float_range(s: &str) -> Result<(Bound<f64>, Bound<f64>), ConversionError> {
1013    let s = s.trim();
1014
1015    // Try interval notation first
1016    if s.starts_with('[') || s.starts_with('(') {
1017        return parse_interval_float(s);
1018    }
1019
1020    // Rust-style
1021    if let Some(eq_pos) = s.find("..=") {
1022        let left = &s[..eq_pos];
1023        let right = &s[eq_pos + 3..];
1024        let min = if left.is_empty() {
1025            Bound::Unbounded
1026        } else {
1027            Bound::Inclusive(parse_f64(left)?)
1028        };
1029        let max = if right.is_empty() {
1030            Bound::Unbounded
1031        } else {
1032            Bound::Inclusive(parse_f64(right)?)
1033        };
1034        Ok((min, max))
1035    } else if let Some(dot_pos) = s.find("..") {
1036        let left = &s[..dot_pos];
1037        let right = &s[dot_pos + 2..];
1038        let min = if left.is_empty() {
1039            Bound::Unbounded
1040        } else {
1041            Bound::Inclusive(parse_f64(left)?)
1042        };
1043        let max = if right.is_empty() {
1044            Bound::Unbounded
1045        } else {
1046            Bound::Exclusive(parse_f64(right)?)
1047        };
1048        Ok((min, max))
1049    } else {
1050        Err(ConversionError::InvalidRangeString(s.to_string()))
1051    }
1052}
1053
1054/// Parse interval notation for floats
1055fn parse_interval_float(s: &str) -> Result<(Bound<f64>, Bound<f64>), ConversionError> {
1056    let left_inclusive = s.starts_with('[');
1057    let right_inclusive = s.ends_with(']');
1058
1059    let inner = &s[1..s.len() - 1];
1060    let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
1061    if parts.len() != 2 {
1062        return Err(ConversionError::InvalidRangeString(s.to_string()));
1063    }
1064
1065    let min = if parts[0].is_empty() {
1066        Bound::Unbounded
1067    } else if left_inclusive {
1068        Bound::Inclusive(parse_f64(parts[0])?)
1069    } else {
1070        Bound::Exclusive(parse_f64(parts[0])?)
1071    };
1072
1073    let max = if parts[1].is_empty() {
1074        Bound::Unbounded
1075    } else if right_inclusive {
1076        Bound::Inclusive(parse_f64(parts[1])?)
1077    } else {
1078        Bound::Exclusive(parse_f64(parts[1])?)
1079    };
1080
1081    Ok((min, max))
1082}
1083
1084fn collect_document_node_paths(doc: &EureDocument) -> IndexMap<NodeId, EurePath> {
1085    fn dfs(
1086        doc: &EureDocument,
1087        node_id: NodeId,
1088        path: &mut Vec<PathSegment>,
1089        out: &mut IndexMap<NodeId, EurePath>,
1090        visited: &mut std::collections::HashSet<NodeId>,
1091    ) {
1092        if !visited.insert(node_id) {
1093            return;
1094        }
1095        out.insert(node_id, EurePath(path.clone()));
1096
1097        let node = doc.node(node_id);
1098        for (ext, &child_id) in node.extensions.iter() {
1099            path.push(PathSegment::Extension(ext.clone()));
1100            dfs(doc, child_id, path, out, visited);
1101            path.pop();
1102        }
1103        match &node.content {
1104            NodeValue::Array(array) => {
1105                for (index, &child_id) in array.iter().enumerate() {
1106                    path.push(PathSegment::ArrayIndex(ArrayIndexKind::Specific(index)));
1107                    dfs(doc, child_id, path, out, visited);
1108                    path.pop();
1109                }
1110            }
1111            NodeValue::Tuple(tuple) => {
1112                for (index, &child_id) in tuple.iter().enumerate() {
1113                    path.push(PathSegment::TupleIndex(index as u8));
1114                    dfs(doc, child_id, path, out, visited);
1115                    path.pop();
1116                }
1117            }
1118            NodeValue::Map(map) => {
1119                for (key, &child_id) in map.iter() {
1120                    path.push(PathSegment::Value(key.clone()));
1121                    dfs(doc, child_id, path, out, visited);
1122                    path.pop();
1123                }
1124            }
1125            NodeValue::PartialMap(map) => {
1126                for (key, &child_id) in map.iter() {
1127                    path.push(PathSegment::from_partial_object_key(key.clone()));
1128                    dfs(doc, child_id, path, out, visited);
1129                    path.pop();
1130                }
1131            }
1132            NodeValue::Primitive(_) | NodeValue::Hole(_) => {}
1133        }
1134    }
1135
1136    let mut out = IndexMap::new();
1137    let mut path = Vec::new();
1138    let mut visited = std::collections::HashSet::new();
1139    dfs(doc, doc.get_root_id(), &mut path, &mut out, &mut visited);
1140    out
1141}
1142
1143fn collect_loaded_document_node_paths(
1144    loaded: &LoadedSchemaSet,
1145) -> IndexMap<ResolvedSchemaUri, IndexMap<NodeId, EurePath>> {
1146    loaded
1147        .documents
1148        .iter()
1149        .map(|(uri, doc)| (uri.clone(), collect_document_node_paths(doc)))
1150        .collect()
1151}
1152
1153fn schema_node_fallback_path(schema_id: SchemaNodeId) -> EurePath {
1154    EurePath(vec![PathSegment::Value(ObjectKey::String(format!(
1155        "schema-node-{}",
1156        schema_id.0
1157    )))])
1158}
1159
1160fn build_layout_strategies(
1161    schema: &SchemaDocument,
1162    source_map: &SchemaSourceMap,
1163    source_node_paths: &IndexMap<ResolvedSchemaUri, IndexMap<NodeId, EurePath>>,
1164) -> LayoutStrategies {
1165    let mut layout = LayoutStrategies::default();
1166
1167    for (schema_id, source) in source_map {
1168        if let Some(path) = source_node_paths
1169            .get(&source.uri)
1170            .and_then(|paths| paths.get(&source.node_id))
1171        {
1172            layout.schema_node_paths.insert(*schema_id, path.clone());
1173        }
1174    }
1175
1176    for schema_index in 0..schema.nodes.len() {
1177        let schema_id = SchemaNodeId(schema_index);
1178        let schema_node = schema.node(schema_id);
1179
1180        let node_path = layout
1181            .schema_node_paths
1182            .get(&schema_id)
1183            .cloned()
1184            .unwrap_or_else(|| schema_node_fallback_path(schema_id));
1185
1186        if let SchemaNodeContent::Array(array_schema) = &schema_node.content
1187            && let Some(style) = array_schema.binding_style
1188        {
1189            layout.by_path.insert(node_path.clone(), style);
1190        }
1191        if let SchemaNodeContent::Tuple(tuple_schema) = &schema_node.content
1192            && let Some(style) = tuple_schema.binding_style
1193        {
1194            layout.by_path.insert(node_path.clone(), style);
1195        }
1196
1197        if let SchemaNodeContent::Record(record_schema) = &schema_node.content {
1198            let mut order = Vec::new();
1199            for (field_name, field_schema) in &record_schema.properties {
1200                order.push(PathSegment::Value(ObjectKey::String(field_name.clone())));
1201                if let Some(style) = field_schema.binding_style {
1202                    let child_path = layout
1203                        .schema_node_paths
1204                        .get(&field_schema.schema)
1205                        .cloned()
1206                        .unwrap_or_else(|| schema_node_fallback_path(field_schema.schema));
1207                    layout.by_path.insert(child_path, style);
1208                }
1209            }
1210            for ext_name in schema_node.ext_types.keys() {
1211                order.push(PathSegment::Extension(ext_name.clone()));
1212            }
1213            if !order.is_empty() {
1214                layout.order_by_path.insert(node_path.clone(), order);
1215            }
1216        }
1217
1218        for ext_schema in schema_node.ext_types.values() {
1219            if let Some(style) = ext_schema.binding_style {
1220                let ext_path = layout
1221                    .schema_node_paths
1222                    .get(&ext_schema.schema)
1223                    .cloned()
1224                    .unwrap_or_else(|| schema_node_fallback_path(ext_schema.schema));
1225                layout.by_path.insert(ext_path, style);
1226            }
1227        }
1228    }
1229
1230    layout
1231}
1232
1233fn parse_bigint(s: &str) -> Result<BigInt, ConversionError> {
1234    s.parse()
1235        .map_err(|_| ConversionError::InvalidRangeString(format!("Invalid integer: {}", s)))
1236}
1237
1238fn parse_f64(s: &str) -> Result<f64, ConversionError> {
1239    s.parse()
1240        .map_err(|_| ConversionError::InvalidRangeString(format!("Invalid float: {}", s)))
1241}
1242
1243/// Convert an EureDocument containing schema definitions to a SchemaDocument
1244///
1245/// This function traverses the document and extracts schema information from:
1246/// - Type paths (`.text`, `.integer`, `.text.rust`, etc.)
1247/// - `$variant` extension for explicit type variants
1248/// - `variants.*` fields for union variant definitions
1249/// - Constraint fields (`min-length`, `max-length`, `pattern`, `range`, etc.)
1250/// - Metadata extensions (`$description`, `$deprecated`, `$default`, `$examples`)
1251///
1252/// # Arguments
1253///
1254/// * `doc` - The EureDocument containing schema definitions
1255///
1256/// # Returns
1257///
1258/// A tuple of (SchemaDocument, SchemaSourceMap) on success, or a ConversionError on failure.
1259/// The SchemaSourceMap maps each schema node ID to its source schema URI and
1260/// document node ID, which can be used for propagating origin information for
1261/// error formatting.
1262///
1263/// # Examples
1264///
1265/// ```ignore
1266/// use eure::parse_to_document;
1267/// use eure_schema::convert::document_to_schema;
1268///
1269/// let input = r#"
1270/// name = `text`
1271/// age = `integer`
1272/// "#;
1273///
1274/// let doc = parse_to_document(input).unwrap();
1275/// let (schema, source_map) = document_to_schema(&doc).unwrap();
1276/// ```
1277/// Recursive entry point for schema conversion. Performs cycle detection by
1278/// inserting `base_uri` into `visited` for the duration of the call.
1279pub(crate) fn convert_with_visited(
1280    loaded: &LoadedSchemaSet,
1281    base_uri: ResolvedSchemaUri,
1282    doc: &EureDocument,
1283    visited: &mut IndexSet<ResolvedSchemaUri>,
1284) -> Result<(SchemaDocument, SchemaSourceMap), ConversionError> {
1285    if !visited.insert(base_uri.clone()) {
1286        return Err(ConversionError::ImportCycle {
1287            cycle: visited.iter().cloned().collect(),
1288            attempted: base_uri,
1289        });
1290    }
1291    let result = Converter::new(doc, loaded, base_uri.clone()).run(visited);
1292    visited.shift_remove(&base_uri);
1293    result
1294}
1295
1296/// Convert a pre-loaded schema import graph to a schema.
1297pub fn loaded_schema_set_to_schema(
1298    loaded: &LoadedSchemaSet,
1299) -> Result<(SchemaDocument, SchemaSourceMap), ConversionError> {
1300    let root_doc =
1301        loaded
1302            .document(&loaded.root_uri)
1303            .ok_or_else(|| ConversionError::ImportNotLoaded {
1304                base: loaded.root_uri.clone(),
1305                alias: "<root>".to_string(),
1306                raw_path: loaded.root_uri.to_string(),
1307            })?;
1308    let mut visited = IndexSet::new();
1309    convert_with_visited(loaded, loaded.root_uri.clone(), root_doc, &mut visited)
1310}
1311
1312/// Convert a pre-loaded schema import graph and compute its layout strategies.
1313pub fn loaded_schema_set_to_schema_with_layout(
1314    loaded: &LoadedSchemaSet,
1315) -> Result<(SchemaDocument, LayoutStrategies, SchemaSourceMap), ConversionError> {
1316    let (schema, source_map) = loaded_schema_set_to_schema(loaded)?;
1317    let source_node_paths = collect_loaded_document_node_paths(loaded);
1318    let layout = build_layout_strategies(&schema, &source_map, &source_node_paths);
1319    Ok((schema, layout, source_map))
1320}
1321
1322/// Backwards-compatible single-document entry point. `$import` entries require
1323/// a pre-loaded graph, so use
1324/// [`loaded_schema_set_to_schema_with_layout`] for import support.
1325pub fn document_to_schema_with_layout(
1326    doc: &EureDocument,
1327) -> Result<(SchemaDocument, LayoutStrategies, SchemaSourceMap), ConversionError> {
1328    let root_uri = ResolvedSchemaUri::Inline("<root>".to_string());
1329    let loaded = LoadedSchemaSet::new(root_uri, doc.clone());
1330    loaded_schema_set_to_schema_with_layout(&loaded)
1331}
1332
1333/// Backwards-compatible single-document entry point. `$import` entries require
1334/// a pre-loaded graph.
1335pub fn document_to_schema(
1336    doc: &EureDocument,
1337) -> Result<(SchemaDocument, SchemaSourceMap), ConversionError> {
1338    let (schema, _layout, source_map) = document_to_schema_with_layout(doc)?;
1339    Ok((schema, source_map))
1340}
1341
1342// =============================================================================
1343// Helpers for inlining imported schemas
1344// =============================================================================
1345
1346/// Deep-copy a `SchemaNodeContent` from a child schema into a parent arena,
1347/// remapping every embedded `SchemaNodeId`.
1348fn remap_node_content(content: &SchemaNodeContent, remap: &[SchemaNodeId]) -> SchemaNodeContent {
1349    let map_id = |id: SchemaNodeId| remap[id.0];
1350    match content {
1351        SchemaNodeContent::Reference(TypeReference::Resolved(id)) => {
1352            SchemaNodeContent::Reference(TypeReference::Resolved(map_id(*id)))
1353        }
1354        SchemaNodeContent::Reference(TypeReference::Named { namespace, name }) => {
1355            SchemaNodeContent::Reference(TypeReference::Named {
1356                namespace: namespace.clone(),
1357                name: name.clone(),
1358            })
1359        }
1360        SchemaNodeContent::Array(a) => SchemaNodeContent::Array(ArraySchema {
1361            item: map_id(a.item),
1362            min_length: a.min_length,
1363            max_length: a.max_length,
1364            unique: a.unique,
1365            contains: a.contains.map(map_id),
1366            binding_style: a.binding_style,
1367        }),
1368        SchemaNodeContent::Map(m) => SchemaNodeContent::Map(MapSchema {
1369            key: map_id(m.key),
1370            value: map_id(m.value),
1371            min_size: m.min_size,
1372            max_size: m.max_size,
1373        }),
1374        SchemaNodeContent::Record(r) => {
1375            let mut properties = IndexMap::new();
1376            for (name, field) in &r.properties {
1377                properties.insert(
1378                    name.clone(),
1379                    RecordFieldSchema {
1380                        schema: map_id(field.schema),
1381                        optional: field.optional,
1382                        binding_style: field.binding_style,
1383                        field_codegen: field.field_codegen.clone(),
1384                    },
1385                );
1386            }
1387            let flatten: Vec<SchemaNodeId> = r.flatten.iter().map(|id| map_id(*id)).collect();
1388            let unknown_fields = match &r.unknown_fields {
1389                UnknownFieldsPolicy::Schema(id) => UnknownFieldsPolicy::Schema(map_id(*id)),
1390                UnknownFieldsPolicy::Deny => UnknownFieldsPolicy::Deny,
1391                UnknownFieldsPolicy::Allow => UnknownFieldsPolicy::Allow,
1392            };
1393            SchemaNodeContent::Record(RecordSchema {
1394                properties,
1395                flatten,
1396                unknown_fields,
1397            })
1398        }
1399        SchemaNodeContent::Tuple(t) => SchemaNodeContent::Tuple(TupleSchema {
1400            elements: t.elements.iter().map(|id| map_id(*id)).collect(),
1401            binding_style: t.binding_style,
1402        }),
1403        SchemaNodeContent::Union(u) => {
1404            let mut variants = IndexMap::new();
1405            for (name, &id) in &u.variants {
1406                variants.insert(name.clone(), map_id(id));
1407            }
1408            SchemaNodeContent::Union(UnionSchema {
1409                variants,
1410                unambiguous: u.unambiguous.clone(),
1411                interop: u.interop.clone(),
1412                deny_untagged: u.deny_untagged.clone(),
1413            })
1414        }
1415        // Primitives + Literal contain no SchemaNodeIds.
1416        SchemaNodeContent::Any => SchemaNodeContent::Any,
1417        SchemaNodeContent::Boolean => SchemaNodeContent::Boolean,
1418        SchemaNodeContent::Null => SchemaNodeContent::Null,
1419        SchemaNodeContent::Text(t) => SchemaNodeContent::Text(t.clone()),
1420        SchemaNodeContent::Integer(i) => SchemaNodeContent::Integer(i.clone()),
1421        SchemaNodeContent::Float(f) => SchemaNodeContent::Float(f.clone()),
1422        SchemaNodeContent::Literal(d) => SchemaNodeContent::Literal(d.clone()),
1423    }
1424}
1425
1426fn remap_ext_types(
1427    ext_types: &IndexMap<Identifier, ExtTypeSchema>,
1428    remap: &[SchemaNodeId],
1429) -> IndexMap<Identifier, ExtTypeSchema> {
1430    let mut out = IndexMap::new();
1431    for (name, ext) in ext_types {
1432        out.insert(
1433            name.clone(),
1434            ExtTypeSchema {
1435                schema: remap[ext.schema.0],
1436                optional: ext.optional,
1437                binding_style: ext.binding_style,
1438            },
1439        );
1440    }
1441    out
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446    use super::*;
1447    use crate::identifiers::{EXT_TYPE, OPTIONAL};
1448    use eure_document::document::node::NodeMap;
1449    use eure_document::eure;
1450    use eure_document::text::Text;
1451    use eure_document::value::PrimitiveValue;
1452
1453    /// Create a document with a record containing a single field with $ext-type extension
1454    fn create_schema_with_field_ext_type(ext_type_content: NodeValue) -> EureDocument {
1455        let mut doc = EureDocument::new();
1456        let root_id = doc.get_root_id();
1457
1458        // Create field value: `text`
1459        let field_value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1460            Text::inline_implicit("text"),
1461        )));
1462
1463        // Add $ext-type extension to the field
1464        let ext_type_id = doc.create_node(ext_type_content);
1465        doc.node_mut(field_value_id)
1466            .extensions
1467            .insert(EXT_TYPE.clone(), ext_type_id);
1468
1469        // Create root as record with field: { name = `text` }
1470        let mut root_map = NodeMap::default();
1471        root_map.insert(ObjectKey::String("name".to_string()), field_value_id);
1472        doc.node_mut(root_id).content = NodeValue::Map(root_map);
1473
1474        doc
1475    }
1476
1477    #[test]
1478    fn extract_ext_types_not_map() {
1479        // name.$ext-type = 1 should error, not silently ignore
1480        // The new parser catches this during parse_record() which expects a map
1481        let doc = create_schema_with_field_ext_type(NodeValue::Primitive(PrimitiveValue::Integer(
1482            1.into(),
1483        )));
1484
1485        let err = document_to_schema(&doc).unwrap_err();
1486        use eure_document::parse::ParseErrorKind;
1487        use eure_document::value::ValueKind;
1488        assert_eq!(
1489            err,
1490            ConversionError::ParseError(ParseError {
1491                node_id: NodeId(2),
1492                kind: ParseErrorKind::TypeMismatch {
1493                    expected: ValueKind::Map,
1494                    actual: ValueKind::Integer,
1495                }
1496            })
1497        );
1498    }
1499
1500    #[test]
1501    fn extract_ext_types_invalid_key() {
1502        // name.$ext-type = { 0 => `text` } should error, not silently ignore
1503        // The parser catches this during parse_ext_types() -> unknown_fields()
1504        let mut doc = EureDocument::new();
1505        let root_id = doc.get_root_id();
1506
1507        // Create field value: `text`
1508        let field_value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1509            Text::inline_implicit("text"),
1510        )));
1511
1512        // Create $ext-type as map with integer key
1513        // The value's node_id is returned in the error since that's the entry with invalid key
1514        let ext_type_value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1515            Text::inline_implicit("text"),
1516        )));
1517        let mut ext_type_map = NodeMap::default();
1518        ext_type_map.insert(ObjectKey::Number(0.into()), ext_type_value_id);
1519
1520        let ext_type_id = doc.create_node(NodeValue::Map(ext_type_map));
1521        doc.node_mut(field_value_id)
1522            .extensions
1523            .insert(EXT_TYPE.clone(), ext_type_id);
1524
1525        // Create root as record
1526        let mut root_map = NodeMap::default();
1527        root_map.insert(ObjectKey::String("name".to_string()), field_value_id);
1528        doc.node_mut(root_id).content = NodeValue::Map(root_map);
1529
1530        let err = document_to_schema(&doc).unwrap_err();
1531        use eure_document::parse::ParseErrorKind;
1532        assert_eq!(
1533            err,
1534            ConversionError::ParseError(ParseError {
1535                // The error points to the value's node_id (the entry with invalid key)
1536                node_id: ext_type_value_id,
1537                kind: ParseErrorKind::InvalidKeyType(ObjectKey::Number(0.into()))
1538            })
1539        );
1540    }
1541
1542    #[test]
1543    fn extract_ext_types_invalid_optional() {
1544        // name.$ext-type.desc.$optional = 1 should error, not silently default to false
1545        // The new parser catches this during field_optional::<bool>() parsing
1546        let mut doc = EureDocument::new();
1547        let root_id = doc.get_root_id();
1548
1549        // Create field value: `text`
1550        let field_value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1551            Text::inline_implicit("text"),
1552        )));
1553
1554        // Create ext-type value with invalid $optional = 1
1555        let ext_type_value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1556            Text::inline_implicit("text"),
1557        )));
1558        let optional_node_id =
1559            doc.create_node(NodeValue::Primitive(PrimitiveValue::Integer(1.into())));
1560        doc.node_mut(ext_type_value_id)
1561            .extensions
1562            .insert(OPTIONAL.clone(), optional_node_id);
1563
1564        // Create $ext-type map
1565        let mut ext_type_map = NodeMap::default();
1566        ext_type_map.insert(ObjectKey::String("desc".to_string()), ext_type_value_id);
1567
1568        let ext_type_id = doc.create_node(NodeValue::Map(ext_type_map));
1569        doc.node_mut(field_value_id)
1570            .extensions
1571            .insert(EXT_TYPE.clone(), ext_type_id);
1572
1573        // Create root as record
1574        let mut root_map = NodeMap::default();
1575        root_map.insert(ObjectKey::String("name".to_string()), field_value_id);
1576        doc.node_mut(root_id).content = NodeValue::Map(root_map);
1577
1578        let err = document_to_schema(&doc).unwrap_err();
1579        use eure_document::parse::ParseErrorKind;
1580        use eure_document::value::ValueKind;
1581        assert_eq!(
1582            err,
1583            ConversionError::ParseError(ParseError {
1584                node_id: NodeId(3),
1585                kind: ParseErrorKind::TypeMismatch {
1586                    expected: ValueKind::Bool,
1587                    actual: ValueKind::Integer,
1588                }
1589            })
1590        );
1591    }
1592
1593    #[test]
1594    fn literal_variant_with_inline_code() {
1595        // Test: { = `any`, $variant => "literal" } should create Literal(Text("any"))
1596        // NOT Any (which would happen if $variant is not detected)
1597        // Note: { = value, $ext => ... } is represented in document model as just the value with extensions
1598        let mut doc = EureDocument::new();
1599        let root_id = doc.get_root_id();
1600
1601        // Create the $variant extension value: "literal"
1602        let variant_value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1603            Text::plaintext("literal"),
1604        )));
1605
1606        // Set root content to the inline code value directly: `any`
1607        // (not wrapped in a map, since { = value } unwraps to just value)
1608        doc.node_mut(root_id).content =
1609            NodeValue::Primitive(PrimitiveValue::Text(Text::inline_implicit("any")));
1610
1611        // Add $variant extension
1612        doc.node_mut(root_id)
1613            .extensions
1614            .insert("variant".parse().unwrap(), variant_value_id);
1615
1616        let (schema, _source_map) =
1617            document_to_schema(&doc).expect("Schema conversion should succeed");
1618
1619        // The root should be a Literal, not Any
1620        let root_content = &schema.node(schema.root).content;
1621        match root_content {
1622            SchemaNodeContent::Literal(doc) => {
1623                // The value should be Text("any")
1624                match &doc.root().content {
1625                    NodeValue::Primitive(PrimitiveValue::Text(t)) => {
1626                        assert_eq!(t.as_str(), "any", "Literal should contain 'any'");
1627                    }
1628                    _ => panic!("Expected Literal with Text primitive, got {:?}", doc),
1629                }
1630            }
1631            SchemaNodeContent::Any => {
1632                panic!("BUG: Got Any instead of Literal - $variant extension not detected!");
1633            }
1634            other => panic!("Expected Literal, got {:?}", other),
1635        }
1636    }
1637
1638    #[test]
1639    fn literal_variant_parsed_from_eure() {
1640        let doc = eure!({
1641            = @code("any")
1642            %variant = "literal"
1643        });
1644
1645        let (schema, _source_map) =
1646            document_to_schema(&doc).expect("Schema conversion should succeed");
1647
1648        let root_content = &schema.node(schema.root).content;
1649        match root_content {
1650            SchemaNodeContent::Literal(doc) => match &doc.root().content {
1651                NodeValue::Primitive(PrimitiveValue::Text(t)) => {
1652                    assert_eq!(t.as_str(), "any", "Literal should contain 'any'");
1653                }
1654                _ => panic!("Expected Literal with Text primitive, got {:?}", doc),
1655            },
1656            SchemaNodeContent::Any => {
1657                panic!(
1658                    "BUG: Got Any instead of Literal - $variant extension not respected for primitive"
1659                );
1660            }
1661            other => panic!("Expected Literal, got {:?}", other),
1662        }
1663    }
1664
1665    #[test]
1666    fn literal_variant_rejects_partial_map() {
1667        let mut doc = EureDocument::new();
1668        let root_id = doc.get_root_id();
1669
1670        let value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Integer(1.into())));
1671        let mut map = eure_document::map::PartialNodeMap::new();
1672        map.push(
1673            eure_document::value::PartialObjectKey::Hole(Some("x".parse().unwrap())),
1674            value_id,
1675        );
1676        doc.node_mut(root_id).content = NodeValue::PartialMap(map);
1677
1678        let variant_value_id = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1679            Text::plaintext("literal"),
1680        )));
1681        doc.node_mut(root_id)
1682            .extensions
1683            .insert("variant".parse().unwrap(), variant_value_id);
1684
1685        let uri = ResolvedSchemaUri::Inline("<test>".to_string());
1686        let loaded = LoadedSchemaSet::new(uri.clone(), doc.clone());
1687        assert_eq!(
1688            Converter::new(&doc, &loaded, uri)
1689                .node_to_document(root_id)
1690                .unwrap_err(),
1691            ConversionError::UnsupportedLiteralValue {
1692                node_id: root_id,
1693                kind: ValueKind::PartialMap,
1694            }
1695        );
1696    }
1697
1698    #[test]
1699    fn union_with_literal_any_variant() {
1700        // Test a union like $types.type which has variants including:
1701        // @variants.any = { = `any`, $variant => "literal" }
1702        // @variants.literal = `any`
1703        // The 'any' variant should match only literal "any", not any value.
1704        let mut doc = EureDocument::new();
1705        let root_id = doc.get_root_id();
1706
1707        // Create the 'any' variant value: { = `any`, $variant => "literal" }
1708        // Note: { = value, $ext => ... } unwraps to just the value with extensions
1709        let any_variant_node = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1710            Text::inline_implicit("any"),
1711        )));
1712        // Add $variant => "literal" extension
1713        let literal_ext = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1714            Text::plaintext("literal"),
1715        )));
1716        doc.node_mut(any_variant_node)
1717            .extensions
1718            .insert("variant".parse().unwrap(), literal_ext);
1719
1720        // Create the 'literal' variant value: `any` (type Any)
1721        let literal_variant_node = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1722            Text::inline_implicit("any"),
1723        )));
1724
1725        // Create the variants map
1726        let mut variants_map = NodeMap::default();
1727        variants_map.insert(ObjectKey::String("any".to_string()), any_variant_node);
1728        variants_map.insert(
1729            ObjectKey::String("literal".to_string()),
1730            literal_variant_node,
1731        );
1732        let variants_node = doc.create_node(NodeValue::Map(variants_map));
1733
1734        // Create root as union
1735        let union_ext = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1736            Text::plaintext("union"),
1737        )));
1738        let untagged_ext = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1739            Text::plaintext("untagged"),
1740        )));
1741        let mut interop_map = NodeMap::default();
1742        interop_map.insert(ObjectKey::String("variant-repr".to_string()), untagged_ext);
1743        let interop_ext = doc.create_node(NodeValue::Map(interop_map));
1744
1745        // Create root map with variants
1746        let mut root_map = NodeMap::default();
1747        root_map.insert(ObjectKey::String("variants".to_string()), variants_node);
1748
1749        doc.node_mut(root_id).content = NodeValue::Map(root_map);
1750        doc.node_mut(root_id)
1751            .extensions
1752            .insert("variant".parse().unwrap(), union_ext);
1753        doc.node_mut(root_id)
1754            .extensions
1755            .insert("interop".parse().unwrap(), interop_ext);
1756
1757        let (schema, _source_map) =
1758            document_to_schema(&doc).expect("Schema conversion should succeed");
1759
1760        // Check the union schema
1761        let root_content = &schema.node(schema.root).content;
1762        match root_content {
1763            SchemaNodeContent::Union(union_schema) => {
1764                // Check 'any' variant is Literal("any"), not Any
1765                let any_variant_id = union_schema
1766                    .variants
1767                    .get("any")
1768                    .expect("'any' variant missing");
1769                let any_content = &schema.node(*any_variant_id).content;
1770                match any_content {
1771                    SchemaNodeContent::Literal(doc) => match &doc.root().content {
1772                        NodeValue::Primitive(PrimitiveValue::Text(t)) => {
1773                            assert_eq!(
1774                                t.as_str(),
1775                                "any",
1776                                "'any' variant should be Literal(\"any\")"
1777                            );
1778                        }
1779                        _ => panic!("'any' variant: expected Text, got {:?}", doc),
1780                    },
1781                    SchemaNodeContent::Any => {
1782                        panic!(
1783                            "BUG: 'any' variant is Any instead of Literal(\"any\") - $variant extension not detected!"
1784                        );
1785                    }
1786                    other => panic!("'any' variant: expected Literal, got {:?}", other),
1787                }
1788
1789                // Check 'literal' variant is Any
1790                let literal_variant_id = union_schema
1791                    .variants
1792                    .get("literal")
1793                    .expect("'literal' variant missing");
1794                let literal_content = &schema.node(*literal_variant_id).content;
1795                match literal_content {
1796                    SchemaNodeContent::Any => {
1797                        // Correct: 'literal' variant should be Any
1798                    }
1799                    other => panic!("'literal' variant: expected Any, got {:?}", other),
1800                }
1801            }
1802            other => panic!("Expected Union, got {:?}", other),
1803        }
1804    }
1805
1806    #[test]
1807    fn extracts_layout_style_rules_from_binding_style_extensions() {
1808        let mut doc = EureDocument::new();
1809        let root_id = doc.get_root_id();
1810        doc.node_mut(root_id).content = NodeValue::empty_map();
1811
1812        let item_id = doc
1813            .add_map_child(ObjectKey::String("item".to_string()), root_id)
1814            .expect("insert item")
1815            .node_id;
1816        doc.node_mut(item_id).content =
1817            NodeValue::Primitive(PrimitiveValue::Text(Text::inline_implicit("integer")));
1818
1819        let style_id = doc
1820            .add_extension("binding-style".parse().unwrap(), item_id)
1821            .expect("insert binding-style")
1822            .node_id;
1823        doc.node_mut(style_id).content =
1824            NodeValue::Primitive(PrimitiveValue::Text(Text::plaintext("binding-block")));
1825
1826        let (_schema, layout, _source_map) =
1827            document_to_schema_with_layout(&doc).expect("conversion succeeds");
1828
1829        let expected_path = EurePath(vec![PathSegment::Value(ObjectKey::String(
1830            "item".to_string(),
1831        ))]);
1832        let style = layout.by_path.get(&expected_path).expect("style for item");
1833        assert_eq!(*style, crate::BindingStyle::BindingBlock);
1834    }
1835
1836    #[test]
1837    fn preserves_record_property_order_in_layout_rules() {
1838        let doc = eure!({
1839            b = @code("integer")
1840            a = @code("integer")
1841        });
1842
1843        let (_schema, layout, _source_map) =
1844            document_to_schema_with_layout(&doc).expect("conversion succeeds");
1845
1846        let expected = vec![
1847            PathSegment::Value(ObjectKey::String("b".to_string())),
1848            PathSegment::Value(ObjectKey::String("a".to_string())),
1849        ];
1850        let root_order = layout
1851            .order_by_path
1852            .get(&EurePath::root())
1853            .expect("root order rule");
1854        assert_eq!(*root_order, expected);
1855    }
1856
1857    #[test]
1858    fn preserves_type_codegen_on_non_record_non_union_type_nodes() {
1859        let mut doc = EureDocument::new();
1860        let root_id = doc.get_root_id();
1861
1862        let type_node = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1863            Text::inline_implicit("text"),
1864        )));
1865        let type_name_node = doc.create_node(NodeValue::Primitive(PrimitiveValue::Text(
1866            Text::plaintext("BadTypeName"),
1867        )));
1868
1869        let mut codegen_map = NodeMap::default();
1870        codegen_map.insert(ObjectKey::String("type".to_string()), type_name_node);
1871        let codegen_node = doc.create_node(NodeValue::Map(codegen_map));
1872        doc.node_mut(type_node)
1873            .extensions
1874            .insert("codegen".parse().unwrap(), codegen_node);
1875
1876        let mut types_map = NodeMap::default();
1877        types_map.insert(ObjectKey::String("bad".to_string()), type_node);
1878        let types_node = doc.create_node(NodeValue::Map(types_map));
1879        doc.node_mut(root_id)
1880            .extensions
1881            .insert("types".parse().unwrap(), types_node);
1882        doc.node_mut(root_id).content =
1883            NodeValue::Primitive(PrimitiveValue::Text(Text::inline_implicit("text")));
1884
1885        let (schema, _source_map) = document_to_schema(&doc)
1886            .expect("type-level $codegen on non-union type nodes should be preserved");
1887
1888        let bad_ident: Identifier = "bad".parse().unwrap();
1889        let bad_type_id = schema.types.get(&bad_ident).expect("type `bad`");
1890        let type_node = schema.node(*bad_type_id);
1891        let TypeCodegen::Record(record) = &type_node.type_codegen else {
1892            panic!("expected non-union type codegen to use Record variant");
1893        };
1894        assert_eq!(record.type_name.as_deref(), Some("BadTypeName"));
1895    }
1896
1897    #[test]
1898    fn rejects_non_productive_reference_cycles() {
1899        let doc = eure!({
1900            %types.a = @code("$types.b")
1901            %types.b = @code("$types.a")
1902            data = @code("$types.a")
1903        });
1904
1905        let err = document_to_schema(&doc).expect_err("cycle must be rejected");
1906        match err {
1907            ConversionError::NonProductiveReferenceCycle(path) => {
1908                assert!(path.contains("$types.a"));
1909                assert!(path.contains("$types.b"));
1910            }
1911            other => panic!("expected NonProductiveReferenceCycle, got {:?}", other),
1912        }
1913    }
1914
1915    // =========================================================================
1916    // Cross-schema reference (`$import` / `$export`) tests.
1917    // =========================================================================
1918
1919    fn to_inline_uri(s: &str) -> ResolvedSchemaUri {
1920        ResolvedSchemaUri::Inline(s.to_string())
1921    }
1922
1923    fn loaded_single(name: &str, doc: EureDocument) -> LoadedSchemaSet {
1924        LoadedSchemaSet::new(to_inline_uri(name), doc)
1925    }
1926
1927    #[test]
1928    fn import_resolves_namespaced_reference() {
1929        // common.schema.eure (lib): defines `$types.username` and exports it.
1930        let common = eure!({
1931            %types.username = @code("text")
1932        });
1933        // user.schema.eure: imports common, references `$types.common.username`.
1934        let user = eure!({
1935            %import.common = "common"
1936            %types.user {
1937                name = @code("$types.common.username")
1938            }
1939        });
1940        let user_uri = to_inline_uri("user");
1941        let common_uri = to_inline_uri("common");
1942        let mut loaded = LoadedSchemaSet::new(user_uri.clone(), user);
1943        loaded.insert_document(common_uri.clone(), common);
1944        loaded.insert_import(user_uri, "common".parse().unwrap(), common_uri);
1945
1946        let (schema, _) = loaded_schema_set_to_schema(&loaded).expect("conversion succeeds");
1947
1948        let user_id: Identifier = "user".parse().unwrap();
1949        let common_id: Identifier = "common".parse().unwrap();
1950        assert!(schema.types.contains_key(&user_id));
1951        assert!(schema.imports.contains_key(&common_id));
1952        let username: Identifier = "username".parse().unwrap();
1953        let imported_username = schema.imports[&common_id].all_types[&username];
1954
1955        // Reference in `user.name` was rewritten to the imported schema node id.
1956        let user_node_id = schema.types.get(&user_id).unwrap();
1957        let SchemaNodeContent::Record(rec) = &schema.node(*user_node_id).content else {
1958            panic!("user must be a record");
1959        };
1960        let name_field = rec.properties.get("name").unwrap();
1961        let SchemaNodeContent::Reference(tr) = &schema.node(name_field.schema).content else {
1962            panic!("name field must be a Reference");
1963        };
1964        assert_eq!(*tr, TypeReference::Resolved(imported_username));
1965    }
1966
1967    #[test]
1968    fn imported_type_does_not_collide_with_similar_local_name() {
1969        let common = eure!({
1970            %types.User = @code("text")
1971        });
1972        let user = eure!({
1973            %import.common = "common"
1974            %types.common__User = @code("integer")
1975            %types.wrapper {
1976                imported = @code("$types.common.User")
1977                local = @code("$types.common__User")
1978            }
1979        });
1980        let user_uri = to_inline_uri("user");
1981        let common_uri = to_inline_uri("common");
1982        let mut loaded = LoadedSchemaSet::new(user_uri.clone(), user);
1983        loaded.insert_document(common_uri.clone(), common);
1984        loaded.insert_import(user_uri, "common".parse().unwrap(), common_uri);
1985
1986        let (schema, _) = loaded_schema_set_to_schema(&loaded).expect("conversion succeeds");
1987        let local_name: Identifier = "common__User".parse().unwrap();
1988        let imported_name: Identifier = "User".parse().unwrap();
1989        let common_alias: Identifier = "common".parse().unwrap();
1990        let local_id = schema.types[&local_name];
1991        let imported_id = schema.imports[&common_alias].all_types[&imported_name];
1992        assert_ne!(local_id, imported_id);
1993
1994        let wrapper_name: Identifier = "wrapper".parse().unwrap();
1995        let SchemaNodeContent::Record(rec) = &schema.node(schema.types[&wrapper_name]).content
1996        else {
1997            panic!("wrapper must be a record");
1998        };
1999        let SchemaNodeContent::Reference(imported_ref) =
2000            &schema.node(rec.properties["imported"].schema).content
2001        else {
2002            panic!("imported field must be a reference");
2003        };
2004        let SchemaNodeContent::Reference(local_ref) =
2005            &schema.node(rec.properties["local"].schema).content
2006        else {
2007            panic!("local field must be a reference");
2008        };
2009        assert_eq!(*imported_ref, TypeReference::Resolved(imported_id));
2010        assert_eq!(*local_ref, TypeReference::Resolved(local_id));
2011    }
2012
2013    #[test]
2014    fn import_cycle_is_rejected() {
2015        let a = eure!({
2016            %import.b = "b"
2017        });
2018        let b = eure!({
2019            %import.a = "a"
2020        });
2021        let a_uri = to_inline_uri("a");
2022        let b_uri = to_inline_uri("b");
2023        let mut loaded = LoadedSchemaSet::new(a_uri.clone(), a);
2024        loaded.insert_document(b_uri.clone(), b);
2025        loaded.insert_import(a_uri.clone(), "b".parse().unwrap(), b_uri.clone());
2026        loaded.insert_import(b_uri, "a".parse().unwrap(), a_uri);
2027
2028        let err = loaded_schema_set_to_schema(&loaded).expect_err("cycle must be rejected");
2029        match err {
2030            ConversionError::ImportCycle { cycle, attempted } => {
2031                let formatted = format_cycle(&cycle, &attempted);
2032                assert!(formatted.contains("a"));
2033                assert!(formatted.contains("b"));
2034            }
2035            other => panic!("expected ImportCycle, got {:?}", other),
2036        }
2037    }
2038
2039    #[test]
2040    fn unknown_import_namespace_is_rejected() {
2041        let user = eure!({
2042            %types.user {
2043                name = @code("$types.common.username")
2044            }
2045        });
2046        let err = loaded_schema_set_to_schema(&loaded_single("user", user))
2047            .expect_err("missing import must be rejected");
2048        match err {
2049            ConversionError::UnknownImportNamespace { namespace, name } => {
2050                assert_eq!(namespace, "common");
2051                assert_eq!(name, "username");
2052            }
2053            other => panic!("expected UnknownImportNamespace, got {:?}", other),
2054        }
2055    }
2056
2057    #[test]
2058    fn type_not_exported_is_rejected() {
2059        // common.schema.eure: declares `username` and `internal-helper`,
2060        // but only exports `username`.
2061        let common = eure!({
2062            %types.username = @code("text")
2063            %types."internal-helper" = @code("text")
2064            %export = ["username"]
2065        });
2066        let user = eure!({
2067            %import.common = "common"
2068            %types.user {
2069                helper = @code("$types.common.internal-helper")
2070            }
2071        });
2072        let user_uri = to_inline_uri("user");
2073        let common_uri = to_inline_uri("common");
2074        let mut loaded = LoadedSchemaSet::new(user_uri.clone(), user);
2075        loaded.insert_document(common_uri.clone(), common);
2076        loaded.insert_import(user_uri, "common".parse().unwrap(), common_uri);
2077
2078        let err =
2079            loaded_schema_set_to_schema(&loaded).expect_err("non-exported type must be rejected");
2080        match err {
2081            ConversionError::TypeNotExported { namespace, name } => {
2082                assert_eq!(namespace, "common");
2083                assert_eq!(name, "internal-helper");
2084            }
2085            other => panic!("expected TypeNotExported, got {:?}", other),
2086        }
2087    }
2088
2089    #[test]
2090    fn omitted_export_exposes_all_local_types() {
2091        // Without `$export`, every locally-declared type is exposed.
2092        let common = eure!({
2093            %types.username = @code("text")
2094            %types.email = @code("text")
2095        });
2096        let user = eure!({
2097            %import.common = "common"
2098            %types.user {
2099                name = @code("$types.common.username")
2100                mail = @code("$types.common.email")
2101            }
2102        });
2103        let user_uri = to_inline_uri("user");
2104        let common_uri = to_inline_uri("common");
2105        let mut loaded = LoadedSchemaSet::new(user_uri.clone(), user);
2106        loaded.insert_document(common_uri.clone(), common);
2107        loaded.insert_import(user_uri, "common".parse().unwrap(), common_uri);
2108        let _ = loaded_schema_set_to_schema(&loaded).expect("omitted export exposes all types");
2109    }
2110
2111    #[test]
2112    fn explicit_export_lists_become_the_export_set() {
2113        let common = eure!({
2114            %types.username = @code("text")
2115            %types.email = @code("text")
2116            %export = ["username"]
2117        });
2118        // Compute the schema for `common` indirectly: import it and inspect
2119        // the imports map via a stub user schema.
2120        let user = eure!({
2121            %import.common = "common"
2122        });
2123        let user_uri = to_inline_uri("user");
2124        let common_uri = to_inline_uri("common");
2125        let mut loaded = LoadedSchemaSet::new(user_uri.clone(), user);
2126        loaded.insert_document(common_uri.clone(), common);
2127        loaded.insert_import(user_uri, "common".parse().unwrap(), common_uri);
2128        let (schema, _) = loaded_schema_set_to_schema(&loaded).expect("conversion succeeds");
2129
2130        let common_id: Identifier = "common".parse().unwrap();
2131        let username: Identifier = "username".parse().unwrap();
2132        let email: Identifier = "email".parse().unwrap();
2133        assert!(schema.imports[&common_id].all_types.contains_key(&username));
2134        assert!(schema.imports[&common_id].all_types.contains_key(&email));
2135        assert!(schema.imports[&common_id].exports.contains(&username));
2136        assert!(!schema.imports[&common_id].exports.contains(&email));
2137    }
2138
2139    #[test]
2140    fn export_lists_must_match_locally_declared_types() {
2141        let common = eure!({
2142            %types.username = @code("text")
2143            %export = ["nope"]
2144        });
2145        let err = loaded_schema_set_to_schema(&loaded_single("common", common))
2146            .expect_err("$export must reference a declared type");
2147        match err {
2148            ConversionError::ExportedNameNotDeclared { name } => {
2149                assert_eq!(name, "nope");
2150            }
2151            other => panic!("expected ExportedNameNotDeclared, got {:?}", other),
2152        }
2153    }
2154}