Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .agents/skills/polyxml-codegen-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@ description: >-

# PolyXML Polyglot Codegen Development & Verification Playbook

## Inherited simpleContent

Normalize synthetic text fields only after root-level import/include merging and
reference validation. Follow complex bases iteratively with a cycle guard and
memoized terminal types; keep the complex `base_type` for inherited attributes.
Preserve named simple aliases, enums, unions and lists rather than flattening away
their identities or constraints. Imported frames can contain boxed references
from cycle cutting; unwrap those only while resolving text inheritance.

Runtime schema flattening must retain one most-derived text slot, otherwise
record lookup finds an inherited empty slot and writing can duplicate text.
TypeScript object inheritance applies only to complex bases, never builtin or
named scalar types. Run `test_inherited_simple_content` and the seven-language
`test_inherited_text_codegen` consumers, including Rust owned/borrowed XML and
Serde, Python dataclass/Pydantic, and strict C# record/class execution. Named
simple-type facets are preserved; this does not implement additional facets
written directly inside a simpleContent restriction or empty/list text codecs.


## XSD attribute values

Use separate `CARGO_TARGET_DIR` paths for revision-specific CLI/native-binding
Expand Down
19 changes: 19 additions & 0 deletions .agents/skills/polyxml-core-engine/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ description: >-

# PolyXML Pure Rust Core Engine Architecture & Patterns

## Inherited simpleContent

Normalize synthetic text fields only after root-level import/include merging and
reference validation. Follow complex bases iteratively with a cycle guard and
memoized terminal types; keep the complex `base_type` for inherited attributes.
Preserve named simple aliases, enums, unions and lists rather than flattening away
their identities or constraints. Imported frames can contain boxed references
from cycle cutting; unwrap those only while resolving text inheritance.

Runtime schema flattening must retain one most-derived text slot, otherwise
record lookup finds an inherited empty slot and writing can duplicate text.
TypeScript object inheritance applies only to complex bases, never builtin or
named scalar types. Run `test_inherited_simple_content` and the seven-language
`test_inherited_text_codegen` consumers, including Rust owned/borrowed XML and
Serde, Python dataclass/Pydantic, and strict C# record/class execution. Named
simple-type facets are preserved; this does not implement additional facets
written directly inside a simpleContent restriction or empty/list text codecs.


This skill documents the high-performance design patterns and strict constraints for
`crates/polyxml-core`.

Expand Down
4 changes: 2 additions & 2 deletions crates/polyxml-core/src/codegen/rust/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2218,8 +2218,8 @@ impl RustCodegen {
/// Decode the simpleContent value field from the element's own text
/// content. Emitted only when the struct has no child elements (the
/// element loop already consumes through `End(start)`), and only for
/// scalar-backed text: a simpleContent chain whose value is typed as its
/// base struct has no scalar form here and keeps its previous behavior.
/// scalar-backed text. The schema post-pass resolves inherited simpleContent
/// to its terminal scalar while retaining named simple-type constraints.
fn emit_text_content_parse(
&self,
out: &mut String,
Expand Down
7 changes: 6 additions & 1 deletion crates/polyxml-core/src/codegen/typescript/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,12 @@ impl TypeScriptCodegen {
self.emit_docstring(out, doc, "");
}

let base_name = s.base_type.as_ref().map(type_ident);
// Scalar simpleContent bases are text types, not object inheritance.
let base_name = s
.base_type
.as_ref()
.filter(|name| matches!(ir.types.get(*name), Some(TypeDef::Struct(_))))
.map(type_ident);
let mut inherited_fields = HashSet::new();
let mut current_base = s.base_type.as_ref();
let mut visited_bases = HashSet::new();
Expand Down
9 changes: 9 additions & 0 deletions crates/polyxml-core/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,8 +433,17 @@ impl ModelSchema {
_ => break,
}
}
// A simpleContent derivation redeclares its text slot. Retain only
// the most-derived slot; inherited attributes still precede it.
let has_own_text = s
.fields
.iter()
.any(|f| f.kind == crate::ir::FieldKind::Text);
for base_s in chain.into_iter().rev() {
for f in &base_s.fields {
if has_own_text && f.kind == crate::ir::FieldKind::Text {
continue;
}
builder = builder.field(build_field(f, ir, visited));
}
}
Expand Down
59 changes: 59 additions & 0 deletions crates/polyxml-core/src/schema_parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ impl XsdParser {
if self.frame_depth == 0 {
resolve_global_field_refs(&mut ir);
validate_type_references(&ir, xml)?;
resolve_simple_content_types(&mut ir)?;
upa::validate(&ir)?;
ir.content_models.retain(|_, model| {
model.has_choice()
Expand Down Expand Up @@ -1921,6 +1922,64 @@ fn parse_union_members(
.collect())
}

/// Resolve inherited text after imports and includes have been merged. Keep
/// named scalar types intact so their facets and generated identities survive.
fn resolve_simple_content_types(ir: &mut SchemaIR) -> Result<(), SchemaError> {
let mut resolved: HashMap<QName, TypeRef> = HashMap::new();
let mut replacements = Vec::new();
for (owner, definition) in &ir.types {
let TypeDef::Struct(structure) = definition else {
continue;
};
for (index, field) in structure.fields.iter().enumerate() {
if field.kind != FieldKind::Text {
continue;
}
let mut current = &field.type_ref;
let mut visited = HashSet::new();
let mut path = Vec::new();
let terminal = loop {
match current {
TypeRef::Boxed(inner) => current = inner,
TypeRef::Named(name) => {
if let Some(terminal) = resolved.get(name) {
break terminal.clone();
}
let Some(TypeDef::Struct(base)) = ir.types.get(name) else {
break current.clone();
};
let Some(text) = base.fields.iter().find(|f| f.kind == FieldKind::Text)
else {
break current.clone();
};
if !visited.insert(name) {
return Err(SchemaError::Resolution(format!(
"cyclic simpleContent base: {}",
name
)));
}
path.push(name.clone());
current = &text.type_ref;
}
_ => break current.clone(),
}
};
for name in path {
resolved.insert(name, terminal.clone());
}
if terminal != field.type_ref {
replacements.push((owner.clone(), index, terminal));
}
}
}
for (owner, index, terminal) in replacements {
if let Some(TypeDef::Struct(structure)) = ir.types.get_mut(&owner) {
structure.fields[index].type_ref = terminal;
}
}
Ok(())
}

/// Replace element-reference placeholders with their global element's declared type.
/// Referenced elements may be defined in an included file parsed later.
fn resolve_global_field_refs(ir: &mut SchemaIR) {
Expand Down
136 changes: 136 additions & 0 deletions crates/polyxml-core/tests/test_inherited_simple_content.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
use polyxml::ir::{FieldKind, PrimitiveType, QName, TypeDef, TypeRef};
use polyxml::schema::ModelSchema;
use polyxml::schema_parser::XsdParser;
use polyxml::{deserialize, serialize, PolyValue};
use std::sync::Arc;

const FIXTURE: &str = include_str!("../../../research/fixtures/inherited_simple_content.xsd");

#[test]
fn inherited_text_resolves_terminal_type_without_losing_struct_bases_or_aliases() {
let ir = XsdParser::new().parse_str(FIXTURE).unwrap();
for (name, base, expected) in [
("Leaf", "Middle", TypeRef::Primitive(PrimitiveType::Int)),
("Middle", "Base", TypeRef::Primitive(PrimitiveType::Int)),
("CodeLeaf", "CodeBase", TypeRef::Named(QName::local("Code"))),
(
"BoundedLeaf",
"BoundedBase",
TypeRef::Named(QName::local("Number")),
),
] {
let TypeDef::Struct(value) = &ir.types[&QName::local(name)] else {
panic!("expected struct")
};
assert_eq!(value.base_type, Some(QName::local(base)));
let text = value
.fields
.iter()
.find(|field| field.kind == FieldKind::Text)
.unwrap();
assert_eq!(text.type_ref, expected, "{name}");
}
}

#[test]
fn inherited_text_round_trips_and_keeps_scalar_constraints() {
let ir = XsdParser::new().parse_str(FIXTURE).unwrap();
for (root, xml, expected) in [
(
"Root",
"<Root unit='m' rank='4' tag='T'>7</Root>",
PolyValue::Int(7),
),
(
"CodeRoot",
"<CodeRoot label='code'>AB</CodeRoot>",
PolyValue::String("AB".into()),
),
(
"BoundedRoot",
"<BoundedRoot>7</BoundedRoot>",
PolyValue::Int(7),
),
] {
let schema = ModelSchema::from_ir(&ir, Some(root)).unwrap();
assert_eq!(
schema
.fields
.iter()
.filter(|f| f.kind == polyxml::schema::FieldKind::Text)
.count(),
1
);
let value = deserialize(xml.as_bytes(), Arc::clone(&schema)).unwrap();
assert_eq!(value.get("value"), Some(&expected), "{root}");
if root == "Root" {
assert_eq!(value.get("unit").and_then(PolyValue::as_str), Some("m"));
assert_eq!(value.get("rank"), Some(&PolyValue::Int(4)));
assert_eq!(value.get("tag").and_then(PolyValue::as_str), Some("T"));
}
let output = serialize(root, &value, &schema, None).unwrap();
assert_eq!(value, deserialize(&output, Arc::clone(&schema)).unwrap());
}
for (root, text) in [("Root", "bad"), ("CodeRoot", "X"), ("BoundedRoot", "0")] {
let schema = ModelSchema::from_ir(&ir, Some(root)).unwrap();
assert!(deserialize(format!("<{root}>{text}</{root}>").as_bytes(), schema).is_err());
}
}

#[test]
fn imported_simple_content_and_parser_cache_keep_terminal_text_types() {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("base.xsd"), r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:base"><xs:complexType name="Base"><xs:simpleContent><xs:extension base="xs:int"><xs:attribute name="unit" type="xs:string"/></xs:extension></xs:simpleContent></xs:complexType></xs:schema>"#).unwrap();
let schema_path = directory.path().join("derived.xsd");
std::fs::write(&schema_path, r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:b="urn:base" xmlns:t="urn:derived" targetNamespace="urn:derived"><xs:import namespace="urn:base" schemaLocation="base.xsd"/><xs:complexType name="Derived"><xs:simpleContent><xs:extension base="b:Base"/></xs:simpleContent></xs:complexType><xs:element name="Root" type="t:Derived"/></xs:schema>"#).unwrap();
let mut parser = XsdParser::new();
let first = parser.parse_file(&schema_path).unwrap();
let second = parser.parse_file(&schema_path).unwrap();
assert_eq!(first.types, second.types);
let TypeDef::Struct(value) = &first.types[&QName::new(Some("urn:derived"), "Derived")] else {
panic!("expected struct")
};
assert_eq!(
value.fields[0].type_ref,
TypeRef::Primitive(PrimitiveType::Int)
);
let schema = ModelSchema::from_ir(&first, Some("Root")).unwrap();
let value = deserialize(b"<Root xmlns='urn:derived' unit='m'>7</Root>", schema).unwrap();
assert_eq!(value.get("value"), Some(&PolyValue::Int(7)));
}

#[test]
fn cyclic_simple_content_is_a_resolution_error() {
let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"><xs:complexType name="A"><xs:simpleContent><xs:extension base="B"/></xs:simpleContent></xs:complexType><xs:complexType name="B"><xs:simpleContent><xs:extension base="A"/></xs:simpleContent></xs:complexType></xs:schema>"#;
let error = XsdParser::new().parse_str(xsd).unwrap_err();
assert!(error.to_string().contains("simpleContent"), "{error}");
}

#[test]
fn inherited_text_preserves_enum_union_and_list_names() {
let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:simpleType name="Choice"><xs:restriction base="xs:string"><xs:enumeration value="A"/></xs:restriction></xs:simpleType>
<xs:simpleType name="Either"><xs:union memberTypes="xs:int xs:string"/></xs:simpleType>
<xs:simpleType name="Numbers"><xs:list itemType="xs:int"/></xs:simpleType>
<xs:complexType name="EnumBase"><xs:simpleContent><xs:extension base="Choice"/></xs:simpleContent></xs:complexType>
<xs:complexType name="EnumLeaf"><xs:simpleContent><xs:extension base="EnumBase"/></xs:simpleContent></xs:complexType>
<xs:complexType name="UnionBase"><xs:simpleContent><xs:extension base="Either"/></xs:simpleContent></xs:complexType>
<xs:complexType name="UnionLeaf"><xs:simpleContent><xs:extension base="UnionBase"/></xs:simpleContent></xs:complexType>
<xs:complexType name="ListBase"><xs:simpleContent><xs:extension base="Numbers"/></xs:simpleContent></xs:complexType>
<xs:complexType name="ListLeaf"><xs:simpleContent><xs:extension base="ListBase"/></xs:simpleContent></xs:complexType>
</xs:schema>"#;
let ir = XsdParser::new().parse_str(xsd).unwrap();
for (leaf, terminal) in [
("EnumLeaf", "Choice"),
("UnionLeaf", "Either"),
("ListLeaf", "Numbers"),
] {
let TypeDef::Struct(value) = &ir.types[&QName::local(leaf)] else {
panic!("expected struct")
};
assert_eq!(
value.fields[0].type_ref,
TypeRef::Named(QName::local(terminal))
);
}
}
Loading