diff --git a/.agents/skills/polyxml-codegen-workflow/SKILL.md b/.agents/skills/polyxml-codegen-workflow/SKILL.md index a522d15..2b1e6a5 100644 --- a/.agents/skills/polyxml-codegen-workflow/SKILL.md +++ b/.agents/skills/polyxml-codegen-workflow/SKILL.md @@ -8,6 +8,34 @@ description: >- # PolyXML Polyglot Codegen Development & Verification Playbook +## XSD attribute values + +Use separate `CARGO_TARGET_DIR` paths for revision-specific CLI/native-binding +verification. A shared debug target can leave another checkout's same-named +top-level binary or cdylib in place even when Cargo reports a fresh build. Probe +a discriminating fixture before claiming a baseline comparison, record the +executable/binding hashes, and retain rejected preflights separately. The runtime +comparison runners already isolate baseline and candidate consumer targets. + +Normalize schema attributes with quick-xml's `normalized_value` before placing +them in the IR. This resolves built-in/numeric references once and normalizes +literal XML attribute whitespace while preserving referenced whitespace. Use the +same decoded values for namespace declarations, QName validation, UPA, facets, +enumerations and default/fixed constraints. Propagate malformed attributes and +reference errors; do not turn them into absent values. Validate attributes even +inside otherwise skipped annotations. Regression fixtures are in +`research/fixtures/schema_attribute_entities.xsd` and +`tests/test_schema_attribute_entities.rs` in the core crate. + +Decoded enum values need target-language string literal escaping at every output +site: metadata attributes, constants, conversion tables and parse matches. Rust +debug string formatting is suitable for Rust, but its `\u{...}` escapes are not +portable to other targets. The shared JSON-compatible literal helper also +escapes U+0085/U+2028/U+2029 because C# treats them as source line terminators; +C++ uses fixed-width octal escapes for ASCII controls. Execute +`test_enum_literal_codegen` with all seven toolchains to verify exact runtime +values and Rust Serde round trips, not just whether generated files exist. + ## C# runtime availability for smoke checks The C# execution fixtures target `net8.0`. An installed .NET 10 SDK/runtime diff --git a/crates/polyxml-core/src/codegen/cpp/mod.rs b/crates/polyxml-core/src/codegen/cpp/mod.rs index 7ed29cc..339529a 100644 --- a/crates/polyxml-core/src/codegen/cpp/mod.rs +++ b/crates/polyxml-core/src/codegen/cpp/mod.rs @@ -553,7 +553,14 @@ endif() } else { "" }; - writeln!(out, " {:?}, T::{}{}", v.value, var_name, comma).unwrap(); + writeln!( + out, + " {}, T::{}{}", + super::cpp_string_literal(&v.value), + var_name, + comma + ) + .unwrap(); } writeln!(out, " );").unwrap(); } @@ -790,8 +797,10 @@ inline int polyxml_integer_compare(std::string_view a,std::string_view b) noexce for (variant, var_name) in enum_def.variants.iter().zip(&variant_names) { writeln!( out, - " case {}::{}: return \"{}\";", - enum_name, var_name, variant.value + " case {}::{}: return {};", + enum_name, + var_name, + super::cpp_string_literal(&variant.value) ) .unwrap(); } @@ -818,8 +827,10 @@ inline int polyxml_integer_compare(std::string_view a,std::string_view b) noexce for (variant, var_name) in enum_def.variants.iter().zip(&variant_names) { writeln!( out, - " if (s == \"{}\") return {}::{};", - variant.value, enum_name, var_name + " if (s == {}) return {}::{};", + super::cpp_string_literal(&variant.value), + enum_name, + var_name ) .unwrap(); } diff --git a/crates/polyxml-core/src/codegen/csharp/mod.rs b/crates/polyxml-core/src/codegen/csharp/mod.rs index f15ae04..31ffede 100644 --- a/crates/polyxml-core/src/codegen/csharp/mod.rs +++ b/crates/polyxml-core/src/codegen/csharp/mod.rs @@ -618,7 +618,13 @@ impl CSharpCodegen { self.emit_docstring(out, doc, &format!("{} ", indent)); } if self.options.emit_xml_attributes { - writeln!(out, "{} [XmlEnum(\"{}\")]", indent, variant.value).unwrap(); + writeln!( + out, + "{} [XmlEnum({})]", + indent, + super::string_literal(&variant.value) + ) + .unwrap(); } writeln!(out, "{} {},", indent, variant_name).unwrap(); } @@ -659,8 +665,11 @@ impl CSharpCodegen { for (variant, variant_name) in e.variants.iter().zip(&variant_names) { writeln!( out, - "{} {}.{} => \"{}\",", - indent, enum_name, variant_name, variant.value + "{} {}.{} => {},", + indent, + enum_name, + variant_name, + super::string_literal(&variant.value) ) .unwrap(); } diff --git a/crates/polyxml-core/src/codegen/go/mod.rs b/crates/polyxml-core/src/codegen/go/mod.rs index 321d4b4..825d3b0 100644 --- a/crates/polyxml-core/src/codegen/go/mod.rs +++ b/crates/polyxml-core/src/codegen/go/mod.rs @@ -577,7 +577,7 @@ func (v {type_name}) MarshalText() ([]byte,error) {{ tokens:=make([]string,0,len let variants = enumeration .variants .iter() - .map(|v| format!("{:?}", v.value)) + .map(|v| super::string_literal(&v.value)) .collect::>() .join(","); writeln!(out,"switch string(value) {{case {variants}: default:return fmt.Errorf(\"invalid enum list item\")}}").unwrap(); @@ -670,8 +670,10 @@ func (v {type_name}) MarshalText() ([]byte,error) {{ tokens:=make([]string,0,len } writeln!( out, - " {} {} = \"{}\"", - const_name, enum_name, variant.value + " {} {} = {}", + const_name, + enum_name, + super::string_literal(&variant.value) ) .unwrap(); } @@ -925,7 +927,7 @@ func (v {type_name}) MarshalText() ([]byte,error) {{ tokens:=make([]string,0,len let comparisons = def .variants .iter() - .map(|v| format!("value == {:?}", v.value)) + .map(|v| format!("value == {}", super::string_literal(&v.value))) .collect::>() .join(" || "); let _ = writeln!( diff --git a/crates/polyxml-core/src/codegen/java/mod.rs b/crates/polyxml-core/src/codegen/java/mod.rs index 5ab5ee3..aa22279 100644 --- a/crates/polyxml-core/src/codegen/java/mod.rs +++ b/crates/polyxml-core/src/codegen/java/mod.rs @@ -597,8 +597,11 @@ impl JavaCodegen { let semi_or_comma = if i + 1 == e.variants.len() { ";" } else { "," }; let _ = writeln!( out, - "{} {}({:?}){}", - indent, const_name, v.value, semi_or_comma + "{} {}({}){}", + indent, + const_name, + super::string_literal(&v.value), + semi_or_comma ); } diff --git a/crates/polyxml-core/src/codegen/mod.rs b/crates/polyxml-core/src/codegen/mod.rs index 9e35479..6ae0135 100644 --- a/crates/polyxml-core/src/codegen/mod.rs +++ b/crates/polyxml-core/src/codegen/mod.rs @@ -24,6 +24,40 @@ pub use python::{ pub use rust::{RustCodegen, RustOptions}; pub use typescript::{TypeScriptBackend, TypeScriptCodegen, TypeScriptOptions}; +/// JSON string escapes are also valid in Python, Go, Java, C# and TypeScript +/// string literals. Rust and C++ need their own escapes for control characters. +pub(crate) fn string_literal(value: &str) -> String { + serde_json::to_string(value) + .expect("serializing a string cannot fail") + // C# treats these Unicode characters as source line terminators even + // inside quoted strings; JSON permits their literal forms. + .replace('\u{85}', "\\u0085") + .replace('\u{2028}', "\\u2028") + .replace('\u{2029}', "\\u2029") +} + +pub(crate) fn cpp_string_literal(value: &str) -> String { + use std::fmt::Write; + let mut literal = String::from("\""); + for character in value.chars() { + match character { + '\\' => literal.push_str("\\\\"), + '"' => literal.push_str("\\\""), + '\n' => literal.push_str("\\n"), + '\r' => literal.push_str("\\r"), + '\t' => literal.push_str("\\t"), + c if c.is_ascii_control() => { + // Three octal digits prevent a following digit from extending + // the escape; universal character names cannot encode these. + write!(literal, "\\{:03o}", c as u32).unwrap(); + } + c => literal.push(c), + } + } + literal.push('"'); + literal +} + /// Prefix every physical XSD documentation line so embedded newlines cannot /// escape a generated source comment. pub(crate) fn write_documentation_lines(out: &mut String, prefix: &str, doc: &str) { diff --git a/crates/polyxml-core/src/codegen/python/mod.rs b/crates/polyxml-core/src/codegen/python/mod.rs index 4c182fb..6858fec 100644 --- a/crates/polyxml-core/src/codegen/python/mod.rs +++ b/crates/polyxml-core/src/codegen/python/mod.rs @@ -521,9 +521,9 @@ impl PythonCodegen { let _ = writeln!( out, - " {} = \"{}\"", + " {} = {}", var_id, - variant.value.replace('"', "\\\"") + super::string_literal(&variant.value) ); } } diff --git a/crates/polyxml-core/src/codegen/rust/mod.rs b/crates/polyxml-core/src/codegen/rust/mod.rs index 6a13123..f4d1722 100644 --- a/crates/polyxml-core/src/codegen/rust/mod.rs +++ b/crates/polyxml-core/src/codegen/rust/mod.rs @@ -724,7 +724,7 @@ impl RustCodegen { write_documentation_lines(out, " /// ", doc); } if self.options.derive_serde { - let _ = writeln!(out, " #[serde(rename = \"{}\")]", variant.value); + let _ = writeln!(out, " #[serde(rename = {:?})]", variant.value); } if self.options.derive_default && idx == 0 { let _ = writeln!(out, " #[default]"); @@ -740,7 +740,7 @@ impl RustCodegen { out.push_str(" pub fn as_str(&self) -> &'static str {\n"); out.push_str(" match self {\n"); for (var_id, val) in &variant_map { - let _ = writeln!(out, " Self::{} => \"{}\",", var_id, val); + let _ = writeln!(out, " Self::{} => {:?},", var_id, val); } if variant_map.is_empty() { out.push_str(" _ => \"\",\n"); @@ -755,7 +755,7 @@ impl RustCodegen { out.push_str(" fn from_str(s: &str) -> std::result::Result {\n"); out.push_str(" match s {\n"); for (var_id, val) in &variant_map { - let _ = writeln!(out, " \"{}\" => Ok(Self::{}),", val, var_id); + let _ = writeln!(out, " {:?} => Ok(Self::{}),", val, var_id); } let _ = writeln!( out, @@ -844,7 +844,7 @@ impl RustCodegen { write_documentation_lines(out, " /// ", doc); } if self.options.derive_serde && !u.is_lexical() { - let _ = writeln!(out, " #[serde(rename = \"{}\")]", branch.xml_name); + let _ = writeln!(out, " #[serde(rename = {:?})]", branch.xml_name); } if self.options.emit_polyxml_attrs && !u.is_lexical() { let _ = writeln!(out, " #[polyxml(element = \"{}\")]", branch.xml_name); diff --git a/crates/polyxml-core/src/codegen/typescript/mod.rs b/crates/polyxml-core/src/codegen/typescript/mod.rs index b01b75a..04812ed 100644 --- a/crates/polyxml-core/src/codegen/typescript/mod.rs +++ b/crates/polyxml-core/src/codegen/typescript/mod.rs @@ -422,7 +422,12 @@ impl TypeScriptCodegen { let mut seen = HashSet::new(); for v in &e.variants { let variant_key = self.unique_variant_name(&v.name, &mut seen); - let _ = writeln!(out, " {}: {:?},", variant_key, v.value); + let _ = writeln!( + out, + " {}: {},", + variant_key, + super::string_literal(&v.value) + ); } out.push_str("} as const;\n\n"); @@ -437,7 +442,7 @@ impl TypeScriptCodegen { let literals = e .variants .iter() - .map(|v| format!("{:?}", v.value)) + .map(|v| super::string_literal(&v.value)) .collect::>() .join(", "); @@ -461,7 +466,7 @@ impl TypeScriptCodegen { let tb_literals = e .variants .iter() - .map(|v| format!("Type.Literal({:?})", v.value)) + .map(|v| format!("Type.Literal({})", super::string_literal(&v.value))) .collect::>() .join(", "); let _ = writeln!( diff --git a/crates/polyxml-core/src/schema_parser/mod.rs b/crates/polyxml-core/src/schema_parser/mod.rs index 1c7e870..42b0b7b 100644 --- a/crates/polyxml-core/src/schema_parser/mod.rs +++ b/crates/polyxml-core/src/schema_parser/mod.rs @@ -233,9 +233,11 @@ impl XsdParser { let name = e.name().into_inner(); let local = strip_prefix(name); if local == "schema" { - for attr in e.attributes().flatten() { + for attr in e.attributes() { + let attr = + attr.map_err(|error| SchemaError::Malformed(error.to_string()))?; let key = attr.key.as_ref(); - let val = attr.value.as_ref(); + let val = attr.normalized_value(quick_xml::XmlVersion::Implicit1_0)?; if key == "targetNamespace" { target_namespace = Some(val.to_string()); @@ -267,7 +269,7 @@ impl XsdParser { match local { "include" | "redefine" => { - if let Some(schema_location) = get_attr_value(e, "schemaLocation") { + if let Some(schema_location) = get_attr_value(e, "schemaLocation")? { if let Some(dir) = base_dir { let inc_path = dir.join(&schema_location); if inc_path.exists() { @@ -299,7 +301,7 @@ impl XsdParser { } } "import" => { - if let Some(schema_location) = get_attr_value(e, "schemaLocation") { + if let Some(schema_location) = get_attr_value(e, "schemaLocation")? { if let Some(dir) = base_dir { let imp_path = dir.join(&schema_location); if imp_path.exists() { @@ -341,7 +343,7 @@ impl XsdParser { &prefixes, &mut ir, )? { - if get_attr_value(e, "abstract") + if get_attr_value(e, "abstract")? .is_some_and(|value| value == "true" || value == "1") { ir.abstract_elements.insert(elem_def.qname.clone()); @@ -360,7 +362,7 @@ impl XsdParser { } "group" => { // Named model group definition. - if let Some(gname) = get_attr_value(e, "name") { + if let Some(gname) = get_attr_value(e, "name")? { let gq = QName::new(target_namespace.as_deref(), gname); let def = self.parse_group_body( &mut reader, @@ -381,7 +383,7 @@ impl XsdParser { match local { "include" | "redefine" => { - if let Some(schema_location) = get_attr_value(e, "schemaLocation") { + if let Some(schema_location) = get_attr_value(e, "schemaLocation")? { if let Some(dir) = base_dir { let inc_path = dir.join(&schema_location); if inc_path.exists() { @@ -413,7 +415,7 @@ impl XsdParser { } } "import" => { - if let Some(schema_location) = get_attr_value(e, "schemaLocation") { + if let Some(schema_location) = get_attr_value(e, "schemaLocation")? { if let Some(dir) = base_dir { let imp_path = dir.join(&schema_location); if imp_path.exists() { @@ -428,8 +430,8 @@ impl XsdParser { e, target_namespace.as_deref(), &prefixes, - ) { - if get_attr_value(e, "abstract") + )? { + if get_attr_value(e, "abstract")? .is_some_and(|value| value == "true" || value == "1") { ir.abstract_elements.insert(elem_def.qname.clone()); @@ -438,8 +440,8 @@ impl XsdParser { } } "attribute" => { - if let Some(name) = get_attr_value(e, "name") { - let type_ref = get_attr_value(e, "type") + if let Some(name) = get_attr_value(e, "name")? { + let type_ref = get_attr_value(e, "type")? .map(|ty| { resolve_type_ref( &ty, @@ -455,14 +457,14 @@ impl XsdParser { } "group" => { // Empty named model group (no particles). - if let Some(gname) = get_attr_value(e, "name") { + if let Some(gname) = get_attr_value(e, "name")? { let gq = QName::new(target_namespace.as_deref(), gname); self.groups.insert(gq, GroupDef::default()); } } "complexType" => { - if let Some(name) = get_attr_value(e, "name") { - let is_abstract = get_attr_value(e, "abstract") + if let Some(name) = get_attr_value(e, "name")? { + let is_abstract = get_attr_value(e, "abstract")? .map(|v| v == "true" || v == "1") .unwrap_or(false); let qname = QName::new(target_namespace.as_deref(), name); @@ -470,7 +472,7 @@ impl XsdParser { qname, base_type: None, is_abstract, - is_mixed: get_attr_value(e, "mixed") + is_mixed: get_attr_value(e, "mixed")? .is_some_and(|value| value == "true" || value == "1"), fields: Vec::new(), documentation: None, @@ -478,7 +480,7 @@ impl XsdParser { } } "simpleType" => { - if let Some(name) = get_attr_value(e, "name") { + if let Some(name) = get_attr_value(e, "name")? { let qname = QName::new(target_namespace.as_deref(), name); ir.add_type(TypeDef::Simple(Box::new(SimpleTypeDef { qname, @@ -512,16 +514,16 @@ impl XsdParser { name_override: Option, ir: &mut SchemaIR, ) -> Result, SchemaError> { - let name = match get_attr_value(start, "name").or(name_override) { + let name = match get_attr_value(start, "name")?.or(name_override) { Some(n) => n, None => return Ok(None), // Anonymous type handled in place }; - let is_abstract = get_attr_value(start, "abstract") + let is_abstract = get_attr_value(start, "abstract")? .map(|v| v == "true" || v == "1") .unwrap_or(false); let is_mixed = - get_attr_value(start, "mixed").is_some_and(|value| value == "true" || value == "1"); + get_attr_value(start, "mixed")?.is_some_and(|value| value == "true" || value == "1"); let qname = QName::new(target_ns, name.clone()); if !is_mixed { @@ -564,7 +566,7 @@ impl XsdParser { in_simple_content = true; } "restriction" if in_simple_content => { - if let Some(base) = get_attr_value(e, "base") { + if let Some(base) = get_attr_value(e, "base")? { let resolved = resolve_qname(&base, target_ns, prefixes); if resolved != qname { base_type = Some(resolved); @@ -576,7 +578,7 @@ impl XsdParser { } } "extension" => { - if let Some(base) = get_attr_value(e, "base") { + if let Some(base) = get_attr_value(e, "base")? { let resolved = resolve_qname(&base, target_ns, prefixes); if resolved != qname { base_type = Some(resolved); @@ -591,7 +593,7 @@ impl XsdParser { } "group" => { // Named model group particle; expanded post-parse. - if let Some(r) = get_attr_value(e, "ref") { + if let Some(r) = get_attr_value(e, "ref")? { group_refs .push((fields.len(), resolve_qname(&r, target_ns, prefixes))); } @@ -599,13 +601,13 @@ impl XsdParser { depth -= 1; } "sequence" | "all" => { - let is_unbounded = get_attr_value(e, "maxOccurs") + let is_unbounded = get_attr_value(e, "maxOccurs")? .map(|v| { v == "unbounded" || v.parse::().map(|n| n > 1).unwrap_or(false) }) .unwrap_or(false); - let min_occurs = get_attr_value(e, "minOccurs") + let min_occurs = get_attr_value(e, "minOccurs")? .and_then(|v| v.parse::().ok()) .unwrap_or(1); compositor_stack.push(CompositorFrame { @@ -622,13 +624,13 @@ impl XsdParser { }); } "choice" => { - let is_unbounded = get_attr_value(e, "maxOccurs") + let is_unbounded = get_attr_value(e, "maxOccurs")? .map(|v| { v == "unbounded" || v.parse::().map(|n| n > 1).unwrap_or(false) }) .unwrap_or(false); - let min_occurs = get_attr_value(e, "minOccurs") + let min_occurs = get_attr_value(e, "minOccurs")? .and_then(|v| v.parse::().ok()) .unwrap_or(1); compositor_stack.push(CompositorFrame { @@ -655,7 +657,7 @@ impl XsdParser { prefixes, in_any_choice, in_unbounded, - ) { + )? { // Consume inline type definitions so nested // fields cannot leak into the parent struct; // extracted types are registered in `ir`. @@ -680,7 +682,7 @@ impl XsdParser { } } "attribute" => { - if let Some(field) = parse_attribute_field(e, target_ns, prefixes) { + if let Some(field) = parse_attribute_field(e, target_ns, prefixes)? { fields.push(field); } } @@ -690,7 +692,7 @@ impl XsdParser { .last() .map(|c| c.kind == CompositorKind::Choice) .unwrap_or(false); - let field = parse_any_field(e, in_choice, in_unbounded); + let field = parse_any_field(e, in_choice, in_unbounded)?; if in_choice { if let Some(frame) = compositor_stack.last_mut() { if frame.kind == CompositorKind::Choice { @@ -721,7 +723,7 @@ impl XsdParser { "extension" | "restriction" if local == "extension" || in_simple_content => { - if let Some(base) = get_attr_value(e, "base") { + if let Some(base) = get_attr_value(e, "base")? { let resolved = resolve_qname(&base, target_ns, prefixes); if resolved != qname { base_type = Some(resolved); @@ -735,7 +737,7 @@ impl XsdParser { } "group" => { // Self-closing group reference; expanded post-parse. - if let Some(r) = get_attr_value(e, "ref") { + if let Some(r) = get_attr_value(e, "ref")? { group_refs .push((fields.len(), resolve_qname(&r, target_ns, prefixes))); } @@ -755,7 +757,7 @@ impl XsdParser { prefixes, in_any_choice, in_unbounded, - ) { + )? { if in_choice { if let Some(frame) = compositor_stack.last_mut() { if frame.kind == CompositorKind::Choice { @@ -773,7 +775,7 @@ impl XsdParser { } } "attribute" => { - if let Some(field) = parse_attribute_field(e, target_ns, prefixes) { + if let Some(field) = parse_attribute_field(e, target_ns, prefixes)? { fields.push(field); } } @@ -783,7 +785,7 @@ impl XsdParser { .last() .map(|c| c.kind == CompositorKind::Choice) .unwrap_or(false); - let field = parse_any_field(e, in_choice, in_unbounded); + let field = parse_any_field(e, in_choice, in_unbounded)?; if in_choice { if let Some(frame) = compositor_stack.last_mut() { if frame.kind == CompositorKind::Choice { @@ -1023,16 +1025,16 @@ impl XsdParser { field: &mut FieldDef, ir: &mut SchemaIR, ) -> Result<(), SchemaError> { - if get_attr_value(element_start, "type").is_some() - || get_attr_value(element_start, "ref").is_some() + if get_attr_value(element_start, "type")?.is_some() + || get_attr_value(element_start, "ref")?.is_some() { // Type already specified โ€” discard (illegal) inline content. skip_subtree(reader)?; return Ok(()); } - let elem_local = get_attr_value(element_start, "name") - .or_else(|| get_attr_value(element_start, "ref").map(|r| strip_prefix(&r).to_string())) + let elem_local = get_attr_value(element_start, "name")? + .or(get_attr_value(element_start, "ref")?.map(|r| strip_prefix(&r).to_string())) .unwrap_or_else(|| field.name.clone()); let mut extracted = false; @@ -1122,7 +1124,7 @@ impl XsdParser { if local == "choice" { in_choice = true; } - let is_unbounded = get_attr_value(e, "maxOccurs") + let is_unbounded = get_attr_value(e, "maxOccurs")? .map(|v| { v == "unbounded" || v.parse::().map(|n| n > 1).unwrap_or(false) @@ -1132,9 +1134,13 @@ impl XsdParser { } "element" => { let in_unbounded = compositor_stack.iter().any(|&b| b); - if let Some(mut field) = - parse_element_field(e, target_ns, prefixes, in_choice, in_unbounded) - { + if let Some(mut field) = parse_element_field( + e, + target_ns, + prefixes, + in_choice, + in_unbounded, + )? { self.consume_inline_element_type( reader, e, target_ns, prefixes, "", &mut field, ir, )?; @@ -1143,7 +1149,7 @@ impl XsdParser { } } "group" => { - if let Some(r) = get_attr_value(e, "ref") { + if let Some(r) = get_attr_value(e, "ref")? { def.group_refs.push(( def.fields.len(), resolve_qname(&r, target_ns, prefixes), @@ -1154,7 +1160,8 @@ impl XsdParser { } "any" => { let in_unbounded = compositor_stack.iter().any(|&b| b); - def.fields.push(parse_any_field(e, in_choice, in_unbounded)); + def.fields + .push(parse_any_field(e, in_choice, in_unbounded)?); } _ => {} } @@ -1164,14 +1171,18 @@ impl XsdParser { match local { "element" => { let in_unbounded = compositor_stack.iter().any(|&b| b); - if let Some(field) = - parse_element_field(e, target_ns, prefixes, in_choice, in_unbounded) - { + if let Some(field) = parse_element_field( + e, + target_ns, + prefixes, + in_choice, + in_unbounded, + )? { def.fields.push(field); } } "group" => { - if let Some(r) = get_attr_value(e, "ref") { + if let Some(r) = get_attr_value(e, "ref")? { def.group_refs.push(( def.fields.len(), resolve_qname(&r, target_ns, prefixes), @@ -1180,7 +1191,8 @@ impl XsdParser { } "any" => { let in_unbounded = compositor_stack.iter().any(|&b| b); - def.fields.push(parse_any_field(e, in_choice, in_unbounded)); + def.fields + .push(parse_any_field(e, in_choice, in_unbounded)?); } _ => {} } @@ -1209,7 +1221,7 @@ impl XsdParser { name_override: Option, ir: &mut SchemaIR, ) -> Result, SchemaError> { - let name = match get_attr_value(start, "name").or(name_override) { + let name = match get_attr_value(start, "name")?.or(name_override) { Some(n) => n, None => return Ok(None), }; @@ -1237,12 +1249,12 @@ impl XsdParser { depth -= 1; } "restriction" => { - if let Some(base) = get_attr_value(e, "base") { + if let Some(base) = get_attr_value(e, "base")? { base_type = resolve_type_ref(&base, target_ns, prefixes); } } "list" => { - if let Some(item) = get_attr_value(e, "itemType") { + if let Some(item) = get_attr_value(e, "itemType")? { base_type = TypeRef::List(Box::new(resolve_type_ref( &item, target_ns, prefixes, ))); @@ -1251,7 +1263,7 @@ impl XsdParser { } } "union" => { - union_branches = Some(parse_union_members(e, target_ns, prefixes)); + union_branches = Some(parse_union_members(e, target_ns, prefixes)?); } "simpleType" if list_requires_inline => { let item_name = @@ -1299,12 +1311,12 @@ impl XsdParser { depth -= 1; } "pattern" => { - if let Some(val) = get_attr_value(e, "value") { + if let Some(val) = get_attr_value(e, "value")? { facets.patterns.push(val); } } "enumeration" => { - if let Some(val) = get_attr_value(e, "value") { + if let Some(val) = get_attr_value(e, "value")? { enum_values.push(EnumValue { name: sanitize_variant_name(&val), value: val.clone(), @@ -1321,12 +1333,12 @@ impl XsdParser { match local { "restriction" => { - if let Some(base) = get_attr_value(e, "base") { + if let Some(base) = get_attr_value(e, "base")? { base_type = resolve_type_ref(&base, target_ns, prefixes); } } "list" => { - if let Some(item) = get_attr_value(e, "itemType") { + if let Some(item) = get_attr_value(e, "itemType")? { base_type = TypeRef::List(Box::new(resolve_type_ref( &item, target_ns, prefixes, ))); @@ -1337,10 +1349,10 @@ impl XsdParser { } } "union" => { - union_branches = Some(parse_union_members(e, target_ns, prefixes)); + union_branches = Some(parse_union_members(e, target_ns, prefixes)?); } "enumeration" => { - if let Some(val) = get_attr_value(e, "value") { + if let Some(val) = get_attr_value(e, "value")? { enum_values.push(EnumValue { name: sanitize_variant_name(&val), value: val.clone(), @@ -1350,43 +1362,44 @@ impl XsdParser { } } "pattern" => { - if let Some(val) = get_attr_value(e, "value") { + if let Some(val) = get_attr_value(e, "value")? { facets.patterns.push(val); } } "minInclusive" => { - facets.min_inclusive = get_attr_value(e, "value"); + facets.min_inclusive = get_attr_value(e, "value")?; } "maxInclusive" => { - facets.max_inclusive = get_attr_value(e, "value"); + facets.max_inclusive = get_attr_value(e, "value")?; } "minExclusive" => { - facets.min_exclusive = get_attr_value(e, "value"); + facets.min_exclusive = get_attr_value(e, "value")?; } "maxExclusive" => { - facets.max_exclusive = get_attr_value(e, "value"); + facets.max_exclusive = get_attr_value(e, "value")?; } "minLength" => { facets.min_length = - get_attr_value(e, "value").and_then(|v| v.parse().ok()); + get_attr_value(e, "value")?.and_then(|v| v.parse().ok()); } "maxLength" => { facets.max_length = - get_attr_value(e, "value").and_then(|v| v.parse().ok()); + get_attr_value(e, "value")?.and_then(|v| v.parse().ok()); } "length" => { - facets.length = get_attr_value(e, "value").and_then(|v| v.parse().ok()); + facets.length = + get_attr_value(e, "value")?.and_then(|v| v.parse().ok()); } "totalDigits" => { facets.total_digits = - get_attr_value(e, "value").and_then(|v| v.parse().ok()); + get_attr_value(e, "value")?.and_then(|v| v.parse().ok()); } "fractionDigits" => { facets.fraction_digits = - get_attr_value(e, "value").and_then(|v| v.parse().ok()); + get_attr_value(e, "value")?.and_then(|v| v.parse().ok()); } "whiteSpace" => { - facets.white_space = get_attr_value(e, "value"); + facets.white_space = get_attr_value(e, "value")?; } _ => {} } @@ -1489,10 +1502,10 @@ impl XsdParser { prefixes: &HashMap, ir: &mut SchemaIR, ) -> Result<(), SchemaError> { - let Some(name) = get_attr_value(start, "name") else { + let Some(name) = get_attr_value(start, "name")? else { return skip_subtree(reader); }; - let mut type_ref = get_attr_value(start, "type") + let mut type_ref = get_attr_value(start, "type")? .map(|ty| resolve_type_ref(&ty, target_ns, prefixes)) .unwrap_or_else(TypeRef::string); loop { @@ -1529,19 +1542,19 @@ impl XsdParser { prefixes: &HashMap, ir: &mut SchemaIR, ) -> Result, SchemaError> { - let name = match get_attr_value(start, "name") { + let name = match get_attr_value(start, "name")? { Some(n) => n, None => return Ok(None), }; let qname = QName::new(target_ns, name.clone()); - let substitution_group = get_attr_value(start, "substitutionGroup") + let substitution_group = get_attr_value(start, "substitutionGroup")? .map(|s| resolve_qname(&s, target_ns, prefixes)); - let nillable = get_attr_value(start, "nillable") + let nillable = get_attr_value(start, "nillable")? .map(|v| v == "true" || v == "1") .unwrap_or(false); - let mut type_ref = get_attr_value(start, "type") + let mut type_ref = get_attr_value(start, "type")? .map(|t| resolve_type_ref(&t, target_ns, prefixes)) .unwrap_or(TypeRef::Primitive(PrimitiveType::AnyType)); @@ -1879,9 +1892,9 @@ fn parse_union_members( union: &BytesStart, target_ns: Option<&str>, prefixes: &HashMap, -) -> Vec { +) -> Result, SchemaError> { let mut names = HashMap::::new(); - get_attr_value(union, "memberTypes") + Ok(get_attr_value(union, "memberTypes")? .unwrap_or_default() .split_whitespace() .map(|member| { @@ -1905,7 +1918,7 @@ fn parse_union_members( documentation: None, } }) - .collect() + .collect()) } /// Replace element-reference placeholders with their global element's declared type. @@ -2053,14 +2066,18 @@ fn strip_prefix(s: &str) -> &str { s.split_once(':').map(|(_, local)| local).unwrap_or(s) } -fn get_attr_value(e: &BytesStart, name: &str) -> Option { - for attr in e.attributes().flatten() { +fn get_attr_value(e: &BytesStart, name: &str) -> Result, SchemaError> { + for attr in e.attributes() { + let attr = attr.map_err(|error| SchemaError::Malformed(error.to_string()))?; let key = attr.key.as_ref(); if key == name || strip_prefix(key) == name { - return Some(attr.value.as_ref().to_string()); + return Ok(Some( + attr.normalized_value(quick_xml::XmlVersion::Implicit1_0)? + .into_owned(), + )); } } - None + Ok(None) } fn resolve_qname(name: &str, target_ns: Option<&str>, prefixes: &HashMap) -> QName { @@ -2098,9 +2115,9 @@ fn parse_element_field( prefixes: &HashMap, in_choice: bool, in_unbounded_compositor: bool, -) -> Option { - let ref_attr = get_attr_value(e, "ref"); - let name_attr = get_attr_value(e, "name"); +) -> Result, SchemaError> { + let ref_attr = get_attr_value(e, "ref")?; + let name_attr = get_attr_value(e, "name")?; let (name, xml_name, namespace) = if let Some(ref r) = ref_attr { let qname = resolve_qname(r, target_ns, prefixes); @@ -2109,13 +2126,15 @@ fn parse_element_field( let namespace = qname.namespace; (field_name, xml_name, namespace) } else { - let n = name_attr?; + let Some(n) = name_attr else { + return Ok(None); + }; let xml_name = n.clone(); let namespace = target_ns.map(Into::into); (n, xml_name, namespace) }; - let type_ref = get_attr_value(e, "type") + let type_ref = get_attr_value(e, "type")? .map(|t| resolve_type_ref(&t, target_ns, prefixes)) .or_else(|| { ref_attr @@ -2127,7 +2146,7 @@ fn parse_element_field( let min_occurs = if in_choice { 0 } else { - get_attr_value(e, "minOccurs") + get_attr_value(e, "minOccurs")? .and_then(|v| v.parse().ok()) .unwrap_or(1) }; @@ -2135,21 +2154,21 @@ fn parse_element_field( let max_occurs = if in_unbounded_compositor { OccursLimit::Unbounded } else { - match get_attr_value(e, "maxOccurs").as_deref() { + match get_attr_value(e, "maxOccurs")?.as_deref() { Some("unbounded") => OccursLimit::Unbounded, Some(v) => OccursLimit::Count(v.parse().unwrap_or(1)), None => OccursLimit::Count(1), } }; - let nillable = get_attr_value(e, "nillable") + let nillable = get_attr_value(e, "nillable")? .map(|v| v == "true" || v == "1") .unwrap_or(false); - let default_value = get_attr_value(e, "default"); - let fixed_value = get_attr_value(e, "fixed"); + let default_value = get_attr_value(e, "default")?; + let fixed_value = get_attr_value(e, "fixed")?; - Some(FieldDef { + Ok(Some(FieldDef { name: sanitize_field_name(&name), xml_name, namespace, @@ -2165,16 +2184,16 @@ fn parse_element_field( documentation: None, facets: None, is_cycle_cut: false, - }) + })) } fn parse_attribute_field( e: &BytesStart, target_ns: Option<&str>, prefixes: &HashMap, -) -> Option { - let ref_attr = get_attr_value(e, "ref"); - let name_attr = get_attr_value(e, "name"); +) -> Result, SchemaError> { + let ref_attr = get_attr_value(e, "ref")?; + let name_attr = get_attr_value(e, "name")?; let (name, xml_name, namespace) = if let Some(ref r) = ref_attr { let qname = resolve_qname(r, target_ns, prefixes); @@ -2183,12 +2202,14 @@ fn parse_attribute_field( let namespace = qname.namespace; (field_name, xml_name, namespace) } else { - let n = name_attr?; + let Some(n) = name_attr else { + return Ok(None); + }; let xml_name = n.clone(); (n, xml_name, None) }; - let type_ref = get_attr_value(e, "type") + let type_ref = get_attr_value(e, "type")? .map(|t| resolve_type_ref(&t, target_ns, prefixes)) .or_else(|| { ref_attr @@ -2197,7 +2218,7 @@ fn parse_attribute_field( }) .unwrap_or(TypeRef::Primitive(PrimitiveType::String)); - let is_required = get_attr_value(e, "use") + let is_required = get_attr_value(e, "use")? .map(|u| u == "required") .unwrap_or(false); @@ -2207,10 +2228,10 @@ fn parse_attribute_field( Cardinality::optional_one() }; - let default_value = get_attr_value(e, "default"); - let fixed_value = get_attr_value(e, "fixed"); + let default_value = get_attr_value(e, "default")?; + let fixed_value = get_attr_value(e, "fixed")?; - Some(FieldDef { + Ok(Some(FieldDef { name: sanitize_field_name(&name), xml_name, namespace, @@ -2223,28 +2244,32 @@ fn parse_attribute_field( documentation: None, facets: None, is_cycle_cut: false, - }) + })) } -fn parse_any_field(e: &BytesStart, in_choice: bool, in_unbounded_compositor: bool) -> FieldDef { +fn parse_any_field( + e: &BytesStart, + in_choice: bool, + in_unbounded_compositor: bool, +) -> Result { let min_occurs = if in_choice { 0 } else { - get_attr_value(e, "minOccurs") + get_attr_value(e, "minOccurs")? .and_then(|v| v.parse::().ok()) .unwrap_or(1) }; let max_occurs = if in_unbounded_compositor { OccursLimit::Unbounded } else { - match get_attr_value(e, "maxOccurs").as_deref() { + match get_attr_value(e, "maxOccurs")?.as_deref() { Some("unbounded") => OccursLimit::Unbounded, Some(v) => OccursLimit::Count(v.parse::().unwrap_or(1)), None => OccursLimit::Count(1), } }; - let namespace = get_attr_value(e, "namespace"); - FieldDef { + let namespace = get_attr_value(e, "namespace")?; + Ok(FieldDef { name: "any".to_string(), xml_name: "*".to_string(), namespace, @@ -2260,7 +2285,7 @@ fn parse_any_field(e: &BytesStart, in_choice: bool, in_unbounded_compositor: boo documentation: None, facets: None, is_cycle_cut: false, - } + }) } fn parse_any_attribute_field(_e: &BytesStart) -> FieldDef { @@ -2284,26 +2309,28 @@ fn parse_empty_global_element( e: &BytesStart, target_ns: Option<&str>, prefixes: &HashMap, -) -> Option { - let name = get_attr_value(e, "name")?; +) -> Result, SchemaError> { + let Some(name) = get_attr_value(e, "name")? else { + return Ok(None); + }; let qname = QName::new(target_ns, name); let substitution_group = - get_attr_value(e, "substitutionGroup").map(|s| resolve_qname(&s, target_ns, prefixes)); - let nillable = get_attr_value(e, "nillable") + get_attr_value(e, "substitutionGroup")?.map(|s| resolve_qname(&s, target_ns, prefixes)); + let nillable = get_attr_value(e, "nillable")? .map(|v| v == "true" || v == "1") .unwrap_or(false); - let type_ref = get_attr_value(e, "type") + let type_ref = get_attr_value(e, "type")? .map(|t| resolve_type_ref(&t, target_ns, prefixes)) .unwrap_or(TypeRef::Primitive(PrimitiveType::AnyType)); - Some(ElementDef { + Ok(Some(ElementDef { qname, type_ref, substitution_group, nillable, documentation: None, - }) + })) } fn sanitize_field_name(name: &str) -> String { @@ -2668,34 +2695,34 @@ fn capture_content_model( } else { Particle::Sequence(items) }; - result.push(repeat(model, &e)); + result.push(repeat(model, &e)?); } else if local == "element" { if let Some(field) = - parse_element_field(&e, target_ns, prefixes, false, false) + parse_element_field(&e, target_ns, prefixes, false, false)? { result.push(repeat( - if get_attr_value(&e, "ref").is_some() { + if get_attr_value(&e, "ref")?.is_some() { Particle::Reference(QName::new(field.namespace, field.xml_name)) } else { Particle::Element(QName::new(field.namespace, field.xml_name)) }, &e, - )); + )?); } reader.read_to_end(e.name())?; } } Event::Empty(e) if strip_prefix(e.name().into_inner()) == "element" => { - if let Some(field) = parse_element_field(&e, target_ns, prefixes, false, false) + if let Some(field) = parse_element_field(&e, target_ns, prefixes, false, false)? { result.push(repeat( - if get_attr_value(&e, "ref").is_some() { + if get_attr_value(&e, "ref")?.is_some() { Particle::Reference(QName::new(field.namespace, field.xml_name)) } else { Particle::Element(QName::new(field.namespace, field.xml_name)) }, &e, - )); + )?); } } Event::End(e) if strip_prefix(e.name().into_inner()) == end => { @@ -2706,16 +2733,16 @@ fn capture_content_model( } } } - fn repeat(model: Particle, e: &BytesStart) -> Particle { - let min = get_attr_value(e, "minOccurs") + fn repeat(model: Particle, e: &BytesStart) -> Result { + let min = get_attr_value(e, "minOccurs")? .and_then(|s| s.parse().ok()) .unwrap_or(1); - let max = match get_attr_value(e, "maxOccurs").as_deref() { + let max = match get_attr_value(e, "maxOccurs")?.as_deref() { Some("unbounded") => None, Some(value) => value.parse().ok(), None => Some(1), }; - if min == 1 && max == Some(1) { + Ok(if min == 1 && max == Some(1) { model } else { Particle::Repeat { @@ -2723,7 +2750,7 @@ fn capture_content_model( min, max, } - } + }) } let mut preview = Reader::from_reader(*reader.get_ref()); preview.config_mut().allow_unmatched_ends = true; @@ -2803,16 +2830,18 @@ fn validate_schema_grammar(xml: &str) -> Result<(), SchemaError> { let mut prefixes = stack.last().map(|(_, p)| p.clone()).unwrap_or_else(|| { HashMap::from([("xml".into(), "http://www.w3.org/XML/1998/namespace".into())]) }); - for attr in element.attributes().flatten() { + for attr in element.attributes() { + let attr = attr.map_err(|error| SchemaError::Malformed(error.to_string()))?; + let value = attr.normalized_value(quick_xml::XmlVersion::Implicit1_0)?; if let Some(prefix) = attr.key.as_ref().strip_prefix("xmlns:") { - prefixes.insert(prefix.into(), attr.value.to_string()); + prefixes.insert(prefix.into(), value.to_string()); } else if attr.key.as_ref() == "xmlns" { - prefixes.insert(String::new(), attr.value.to_string()); + prefixes.insert(String::new(), value.to_string()); } } let local = strip_prefix(element.name().into_inner()); if stack.is_empty() && local == "schema" { - schema_namespace = get_attr_value(&element, "targetNamespace"); + schema_namespace = get_attr_value(&element, "targetNamespace")?; } let position = reader.buffer_position() as usize; line += xml[line_position..position] @@ -2849,14 +2878,16 @@ fn validate_schema_grammar(xml: &str) -> Result<(), SchemaError> { "Illegal child {local} under simpleType at line {line}" ))); } - for attr in element.attributes().flatten() { + for attr in element.attributes() { + let attr = attr.map_err(|error| SchemaError::Malformed(error.to_string()))?; + let value = attr.normalized_value(quick_xml::XmlVersion::Implicit1_0)?; if !annotation && matches!( attr.key.as_ref(), "type" | "base" | "itemType" | "memberTypes" | "ref" ) { - for value in attr.value.split_whitespace() { + for value in value.split_whitespace() { if let Some((prefix, local)) = value.split_once(':') { let Some(namespace) = prefixes.get(prefix) else { return Err(SchemaError::Resolution(format!( diff --git a/crates/polyxml-core/tests/test_enum_literal_codegen.rs b/crates/polyxml-core/tests/test_enum_literal_codegen.rs new file mode 100644 index 0000000..384b65d --- /dev/null +++ b/crates/polyxml-core/tests/test_enum_literal_codegen.rs @@ -0,0 +1,233 @@ +//! Compile generated enum consumers and compare runtime values with independent +//! data files. Source substring checks cannot catch escaping that changes values. +use std::{fs, path::Path, process::Command}; + +use polyxml::codegen::*; +use polyxml::ir::{QName, TypeDef}; +use polyxml::schema_parser::XsdParser; + +fn run(directory: &Path, program: &str, args: &[&str]) { + let output = Command::new(program) + .args(args) + .current_dir(directory) + .env("CARGO_BUILD_JOBS", "1") + .output() + .unwrap_or_else(|error| panic!("running {program}: {error}")); + assert!( + output.status.success(), + "{program} {args:?}:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn enum_literals_compile_and_preserve_values_in_all_seven_languages() { + let ir = XsdParser::new() + .parse_str(include_str!( + "../../../research/fixtures/enum_literal_escaping.xsd" + )) + .unwrap(); + let TypeDef::Enum(status) = &ir.types[&QName::local("Status")] else { + panic!("expected Status enum"); + }; + let expected = [ + "R&D", + "\"quoted\" 'value' ", + "cafรฉ ๐Ÿ˜€", + "literal &", + "slash\\path\\u000a", + "tab\tline\ncarriage\rtail", + "\u{85}next\u{2028}line", + ]; + assert_eq!( + status + .variants + .iter() + .map(|v| v.value.as_str()) + .collect::>(), + expected + ); + let root = tempfile::tempdir().unwrap(); + let json = serde_json::to_vec(&expected).unwrap(); + let mut binary = Vec::new(); + for value in expected { + binary.extend_from_slice(&(value.len() as u32).to_le_bytes()); + binary.extend_from_slice(value.as_bytes()); + } + let consumer = |name: &str| { + let path = root.path().join(name); + fs::create_dir(&path).unwrap(); + fs::write(path.join("expected.json"), &json).unwrap(); + fs::write(path.join("expected.bin"), &binary).unwrap(); + path + }; + + let rust = consumer("rust"); + fs::create_dir(rust.join("src")).unwrap(); + let core = Path::new(env!("CARGO_MANIFEST_DIR")); + fs::write(rust.join("Cargo.toml"), format!("[package]\nname=\"enum-literal-test\"\nversion=\"0.0.0\"\nedition=\"2021\"\n[dependencies]\npolyxml={{path={core:?}}}\nquick-xml=\"0.42\"\nserde={{version=\"1\",features=[\"derive\"]}}\nserde_json=\"1\"\n")).unwrap(); + fs::write( + rust.join("src/models.rs"), + RustCodegen::new(RustOptions::default()).generate_module(&ir), + ) + .unwrap(); + fs::write(rust.join("src/main.rs"), r#"mod models; +fn main() { + let expected: Vec = serde_json::from_slice(&std::fs::read("expected.json").unwrap()).unwrap(); + for text in expected { + let value: models::Status = text.parse().unwrap(); + assert_eq!(value.as_str(), text); + let json = serde_json::to_string(&value).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), text); + assert_eq!(serde_json::from_str::(&json).unwrap(), value); + } +}"#).unwrap(); + let output = Command::new("cargo") + .args(["run", "--offline", "--quiet"]) + .env( + "CARGO_TARGET_DIR", + core.join("../../target/enum-literal-tests"), + ) + .env("CARGO_BUILD_JOBS", "1") + .current_dir(&rust) + .output() + .unwrap(); + assert!( + output.status.success(), + "Rust: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let python = consumer("python"); + fs::write( + python.join("models.py"), + PythonCodegen::new(PythonOptions::default()).generate_module(&ir), + ) + .unwrap(); + fs::write(python.join("check.py"), "import json\nfrom models import Status\nwith open('expected.json', encoding='utf-8') as f:\n expected = json.load(f)\nassert [v.value for v in Status] == expected\nfor value in expected:\n assert Status(value).value == value\n").unwrap(); + run(&python, "python3", &["check.py"]); + + let go = consumer("go"); + fs::write(go.join("go.mod"), "module literals\n\ngo 1.22\n").unwrap(); + fs::write( + go.join("models.go"), + GoCodegen::new(GoOptions::default()).generate_module(&ir), + ) + .unwrap(); + fs::write(go.join("models_test.go"), r#"package models +import("encoding/json";"os";"testing") +func TestValues(t *testing.T) { + data,err:=os.ReadFile("expected.json");if err!=nil{t.Fatal(err)};var expected []string + if err=json.Unmarshal(data,&expected);err!=nil{t.Fatal(err)} + for _,text:=range expected{value:=Status(text);if !value.IsValid(){t.Fatal(text)};encoded,err:=json.Marshal(value);if err!=nil{t.Fatal(err)};var actual string;if err=json.Unmarshal(encoded,&actual);err!=nil{t.Fatal(err)};if actual!=text{t.Fatal(actual,text)}} +}"#).unwrap(); + run(&go, "go", &["test", "./..."]); + + let cpp = consumer("cpp"); + fs::write( + cpp.join("models.hpp"), + CppCodegen::new(CppOptions::default()).generate_header(&ir), + ) + .unwrap(); + fs::write( + cpp.join("main.cpp"), + r#"#include "models.hpp" +#include +#include +int main(){std::ifstream input("expected.bin",std::ios::binary);unsigned char size[4]; + while(input.read(reinterpret_cast(size),4)){ + unsigned count=size[0]|(unsigned(size[1])<<8)|(unsigned(size[2])<<16)|(unsigned(size[3])<<24); + std::string expected(count,'\0');input.read(expected.data(),count); + auto value=polyxml::generated::status_from_string(expected); + if(!value||polyxml::generated::to_string(*value)!=expected)throw std::runtime_error(expected); + }}"#, + ) + .unwrap(); + run( + &cpp, + "c++", + &[ + "-std=c++20", + "-Wall", + "-Wextra", + "-Werror", + "main.cpp", + "-o", + "check", + ], + ); + run(&cpp, "./check", &[]); + + let java = consumer("java"); + fs::write( + java.join("Models.java"), + JavaCodegen::new(JavaOptions { + package_name: String::new(), + ..Default::default() + }) + .generate_module(&ir, "Models"), + ) + .unwrap(); + fs::write(java.join("Check.java"), r#"import java.nio.*;import java.nio.file.*;import java.nio.charset.*; +public class Check {public static void main(String[] args)throws Exception{ + ByteBuffer input=ByteBuffer.wrap(Files.readAllBytes(Path.of("expected.bin"))).order(ByteOrder.LITTLE_ENDIAN); + while(input.hasRemaining()){byte[] bytes=new byte[input.getInt()];input.get(bytes);String text=new String(bytes,StandardCharsets.UTF_8); + if(!Models.Status.fromValue(text).getValue().equals(text))throw new AssertionError(text); + }}} +"#).unwrap(); + run(&java, "javac", &["Models.java", "Check.java"]); + run(&java, "java", &["Check"]); + + let csharp = consumer("csharp"); + fs::write(csharp.join("Check.csproj"), "Exenet8.0enableenable").unwrap(); + fs::write( + csharp.join("Models.cs"), + CSharpCodegen::new(CSharpOptions { + namespace: "Models".into(), + ..Default::default() + }) + .generate_module(&ir), + ) + .unwrap(); + fs::write( + csharp.join("Program.cs"), + r#"using Models;using System.Text;using System.Xml.Serialization;using System.Reflection; +using var reader=new BinaryReader(File.OpenRead("expected.bin")); +foreach(var value in Enum.GetValues()){ + string expected=Encoding.UTF8.GetString(reader.ReadBytes(reader.ReadInt32())); + if(value.ToXmlValue()!=expected)throw new Exception(expected); + var attribute=typeof(Status).GetField(value.ToString())!.GetCustomAttribute()!; + if(attribute.Name!=expected)throw new Exception(attribute.Name); +} +if(reader.BaseStream.Position!=reader.BaseStream.Length)throw new Exception("missing variants"); +"#, + ) + .unwrap(); + run( + &csharp, + "dotnet", + &["run", "--project", "Check.csproj", "--verbosity", "quiet"], + ); + + let typescript = consumer("typescript"); + fs::write( + typescript.join("models.ts"), + TypeScriptCodegen::new(TypeScriptOptions::default()).generate_module(&ir), + ) + .unwrap(); + fs::write(typescript.join("check.cjs"), "const fs=require('fs');const {Status}=require('./models.js');const assert=require('assert');assert.deepStrictEqual(Object.values(Status),JSON.parse(fs.readFileSync('expected.json','utf8')));\n").unwrap(); + run( + &typescript, + "tsc", + &[ + "--target", + "es2020", + "--module", + "commonjs", + "--strict", + "models.ts", + ], + ); + run(&typescript, "node", &["check.cjs"]); +} diff --git a/crates/polyxml-core/tests/test_schema_attribute_entities.rs b/crates/polyxml-core/tests/test_schema_attribute_entities.rs new file mode 100644 index 0000000..a31540b --- /dev/null +++ b/crates/polyxml-core/tests/test_schema_attribute_entities.rs @@ -0,0 +1,114 @@ +use std::sync::Arc; + +use polyxml::ir::{QName, TypeDef, TypeRef}; +use polyxml::schema::ModelSchema; +use polyxml::schema_parser::XsdParser; +use polyxml::{deserialize, serialize, PolyValue}; + +const FIXTURE: &str = include_str!("../../../research/fixtures/schema_attribute_entities.xsd"); + +#[test] +fn attribute_entities_reach_enum_facets_and_defaults_in_value_space() { + let ir = XsdParser::new().parse_str(FIXTURE).unwrap(); + let TypeDef::Enum(status) = &ir.types[&QName::local("Status")] else { + panic!("Status must be an enum"); + }; + assert_eq!( + status + .variants + .iter() + .map(|v| v.value.as_str()) + .collect::>(), + [ + "R&D", + "\"quoted\" 'value' ", + "cafรฉ ๐Ÿ˜€", + "literal &" + ] + ); + let TypeDef::Simple(code) = &ir.types[&QName::local("Code")] else { + panic!("Code must retain its pattern"); + }; + assert_eq!(code.facets.patterns, ["[A-Z]&[A-Z]"]); + let schema = ModelSchema::from_ir(&ir, Some("Root")).unwrap(); + for status in [ + "R&D", + ""quoted" 'value' <tag>", + "cafรฉ ๐Ÿ˜€", + "literal &amp;", + ] { + let xml = + format!("{status}A&B"); + let value = deserialize(xml.as_bytes(), Arc::clone(&schema)).unwrap(); + for (name, expected) in [ + ("fixed", "R&D"), + ("default", "cafรฉ"), + ("label", "