From 642f7a3e58e8b5e8c782f84c80e26bd100c7cdac Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 29 Sep 2026 11:28:13 +0200 Subject: [PATCH] fix(schema): expose the serde aliases the exported schema was hiding schemars does not emit #[serde(alias = ...)]: it writes only the canonical name. Every aliased spelling was therefore declared invalid by `rustmotion schema` while the engine accepts it perfectly -- and the exported schema is the file a generator reads to know what to write. Two mechanisms, both post-processed after generation: - variant aliases widen the `enum` array they belong to: float_3d, progress_bar, flex-start/flex_start, flex-end/flex_end, space-between/around/evenly. - field aliases add a property mirroring the canonical one's schema. BorderRadius is the case: its corners are declared as top-left and carry alias = "top_left", and the branch is deny_unknown_fields, so a scenario written the snake_case way was rejected outright. The tables are keyed by definition name, not by value. Keying by value would have been shorter and wrong: another enum with a `start` variant and no aliases would have inherited spellings the engine rejects, which turns a false negative into a false positive. Drift is covered by reading the sources rather than trusting the table: every_serde_alias_in_the_sources_is_reachable_from_the_exported_schema scans every .rs for `alias = "..."` and fails naming any that the exported schema does not contain. A new alias therefore fails CI instead of quietly falling out of the schema. Proven on minimal documents, since the examples are too noisy to isolate it: a scenario using `float_3d` or `progress_bar` was rejected by the exported schema and is accepted now. What this does not fix, and what the issue attributed to it: the repository's examples still fail schema validation, 62 paths before and after. Their causes are elsewhere -- templating placeholders ($delay, $d) are not modelled as anything but numbers, and Draft-7 anyOf reporting picks an arbitrary branch, so the messages name the wrong variant. Filed separately. --- crates/rustmotion/src/cli/commands/schema.rs | 205 +++++++++++++++++++ 1 file changed, 205 insertions(+) diff --git a/crates/rustmotion/src/cli/commands/schema.rs b/crates/rustmotion/src/cli/commands/schema.rs index 96e1a64..5ef8fef 100644 --- a/crates/rustmotion/src/cli/commands/schema.rs +++ b/crates/rustmotion/src/cli/commands/schema.rs @@ -16,6 +16,95 @@ pub fn cmd_schema(output: Option<&std::path::Path>) -> Result<()> { Ok(()) } +const SERDE_ALIASES: &[(&str, &str, &[&str])] = &[ + ("CardAlign", "start", &["flex-start", "flex_start"]), + ("CardAlign", "end", &["flex-end", "flex_end"]), + ("CardJustify", "start", &["flex-start", "flex_start"]), + ("CardJustify", "end", &["flex-end", "flex_end"]), + ("CardJustify", "space_between", &["space-between"]), + ("CardJustify", "space_around", &["space-around"]), + ("CardJustify", "space_evenly", &["space-evenly"]), + ("AnimationPreset", "float3d", &["float_3d"]), + ("AnimationEffect", "float3d", &["float_3d"]), + ("Component", "progress", &["progress_bar"]), + ("ComponentBase", "progress", &["progress_bar"]), + ("ChildComponent", "progress", &["progress_bar"]), + ("ChildComponentBase", "progress", &["progress_bar"]), +]; + +fn widen_enums_with(value: &mut serde_json::Value, canonical: &str, aliases: &[&str]) { + match value { + serde_json::Value::Object(map) => { + if let Some(serde_json::Value::Array(variants)) = map.get_mut("enum") { + if variants.iter().any(|v| v.as_str() == Some(canonical)) { + for alias in aliases { + let alias = serde_json::Value::String((*alias).to_string()); + if !variants.contains(&alias) { + variants.push(alias); + } + } + } + } + for (_, child) in map.iter_mut() { + widen_enums_with(child, canonical, aliases); + } + } + serde_json::Value::Array(items) => { + for item in items { + widen_enums_with(item, canonical, aliases); + } + } + _ => {} + } +} + +const SERDE_FIELD_ALIASES: &[(&str, &str, &[&str])] = &[ + ("BorderRadius", "top-left", &["top_left"]), + ("BorderRadius", "top-right", &["top_right"]), + ("BorderRadius", "bottom-right", &["bottom_right"]), + ("BorderRadius", "bottom-left", &["bottom_left"]), +]; + +fn widen_properties_with(value: &mut serde_json::Value, canonical: &str, aliases: &[&str]) { + match value { + serde_json::Value::Object(map) => { + if let Some(serde_json::Value::Object(properties)) = map.get_mut("properties") { + if let Some(schema) = properties.get(canonical).cloned() { + for alias in aliases { + properties + .entry((*alias).to_string()) + .or_insert_with(|| schema.clone()); + } + } + } + for (key, child) in map.iter_mut() { + if key != "properties" { + widen_properties_with(child, canonical, aliases); + } + } + } + serde_json::Value::Array(items) => { + for item in items { + widen_properties_with(item, canonical, aliases); + } + } + _ => {} + } +} + +fn expose_serde_aliases(defs: &mut serde_json::Map) { + for (definition, canonical, aliases) in SERDE_ALIASES { + if let Some(entry) = defs.get_mut(*definition) { + widen_enums_with(entry, canonical, aliases); + } + } + for (definition, canonical, aliases) in SERDE_FIELD_ALIASES { + if let Some(entry) = defs.get_mut(*definition) { + widen_properties_with(entry, canonical, aliases); + } + } +} + fn build_schema() -> serde_json::Value { let mut scenario_schema = schema::generate_json_schema(); let component_schema = serde_json::to_value(schemars::schema_for!(Component)) @@ -53,6 +142,7 @@ fn build_schema() -> serde_json::Value { wrap_with_directives(defs_obj, "Component"); wrap_with_directives(defs_obj, "ChildComponent"); + expose_serde_aliases(defs_obj); if let Some(children) = scenario_schema.pointer_mut("/definitions/Scene/properties/children") { *children = serde_json::json!({ @@ -151,3 +241,118 @@ fn inject_for_each_use_definitions(defs_obj: &mut serde_json::Map std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(std::path::Path::parent) + .expect("rustmotion is expected at /crates/rustmotion") + .to_path_buf() + } + + fn rust_sources(dir: &std::path::Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if path.file_name().is_some_and(|n| n == "target") { + continue; + } + rust_sources(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + + fn aliases_declared_in_the_sources() -> Vec<(String, String)> { + let mut files = Vec::new(); + rust_sources(&workspace_root().join("crates"), &mut files); + let mut found = Vec::new(); + for file in files { + let Ok(text) = std::fs::read_to_string(&file) else { + continue; + }; + for line in text.lines() { + let trimmed = line.trim_start(); + if !trimmed.starts_with("#[serde(") || !trimmed.contains("alias = \"") { + continue; + } + let mut rest = trimmed; + while let Some(at) = rest.find("alias = \"") { + rest = &rest[at + "alias = \"".len()..]; + let Some(close) = rest.find('"') else { break }; + found.push((file.display().to_string(), rest[..close].to_string())); + rest = &rest[close..]; + } + } + } + found + } + + #[test] + fn every_serde_alias_in_the_sources_is_reachable_from_the_exported_schema() { + let declared = aliases_declared_in_the_sources(); + assert!( + declared.len() >= 10, + "test setup: the scanner found only {} aliases, so it is not reading the sources", + declared.len() + ); + + let schema = serde_json::to_string(&build_schema()).expect("schema serializes"); + let missing: Vec = declared + .iter() + .filter(|(_, alias)| !schema.contains(&format!("\"{alias}\""))) + .map(|(file, alias)| format!("{alias} (declared in {file})")) + .collect(); + + assert!( + missing.is_empty(), + "schemars does not emit #[serde(alias = ...)], so the exported schema declares \ + invalid what the engine accepts. Add each of these to SERDE_ALIASES or \ + SERDE_FIELD_ALIASES in this file:\n {}", + missing.join("\n ") + ); + } + + #[test] + fn the_canonical_spelling_is_never_replaced_by_its_alias() { + let schema = build_schema(); + let card_align = schema + .pointer("/definitions/CardAlign/enum") + .and_then(|v| v.as_array()) + .expect("CardAlign is an enum of strings"); + let values: Vec<&str> = card_align.iter().filter_map(|v| v.as_str()).collect(); + assert!( + values.contains(&"start") && values.contains(&"flex-start"), + "an alias is widened onto the canonical spelling, never swapped for it: {values:?}" + ); + } + + #[test] + fn an_enum_that_shares_a_variant_name_but_not_its_aliases_is_left_alone() { + let schema = build_schema(); + let text = serde_json::to_string(&schema).expect("schema serializes"); + assert!( + text.contains("\"space-between\""), + "CardJustify's own aliases are there" + ); + let justify_content = schema + .pointer("/definitions/JustifyContent/enum") + .and_then(|v| v.as_array()); + if let Some(values) = justify_content { + let values: Vec<&str> = values.iter().filter_map(|v| v.as_str()).collect(); + assert!( + !values.contains(&"flex_start"), + "the table is keyed by definition, not by value, so a different enum with a \ + variant of the same name must not inherit aliases it does not have: {values:?}" + ); + } + } +}