Skip to content

Commit ef0b69e

Browse files
authored
fix(schema): expose the serde aliases the exported schema was hiding (#423)
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.
1 parent 1fd3960 commit ef0b69e

1 file changed

Lines changed: 205 additions & 0 deletions

File tree

‎crates/rustmotion/src/cli/commands/schema.rs‎

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,95 @@ pub fn cmd_schema(output: Option<&std::path::Path>) -> Result<()> {
1616
Ok(())
1717
}
1818

19+
const SERDE_ALIASES: &[(&str, &str, &[&str])] = &[
20+
("CardAlign", "start", &["flex-start", "flex_start"]),
21+
("CardAlign", "end", &["flex-end", "flex_end"]),
22+
("CardJustify", "start", &["flex-start", "flex_start"]),
23+
("CardJustify", "end", &["flex-end", "flex_end"]),
24+
("CardJustify", "space_between", &["space-between"]),
25+
("CardJustify", "space_around", &["space-around"]),
26+
("CardJustify", "space_evenly", &["space-evenly"]),
27+
("AnimationPreset", "float3d", &["float_3d"]),
28+
("AnimationEffect", "float3d", &["float_3d"]),
29+
("Component", "progress", &["progress_bar"]),
30+
("ComponentBase", "progress", &["progress_bar"]),
31+
("ChildComponent", "progress", &["progress_bar"]),
32+
("ChildComponentBase", "progress", &["progress_bar"]),
33+
];
34+
35+
fn widen_enums_with(value: &mut serde_json::Value, canonical: &str, aliases: &[&str]) {
36+
match value {
37+
serde_json::Value::Object(map) => {
38+
if let Some(serde_json::Value::Array(variants)) = map.get_mut("enum") {
39+
if variants.iter().any(|v| v.as_str() == Some(canonical)) {
40+
for alias in aliases {
41+
let alias = serde_json::Value::String((*alias).to_string());
42+
if !variants.contains(&alias) {
43+
variants.push(alias);
44+
}
45+
}
46+
}
47+
}
48+
for (_, child) in map.iter_mut() {
49+
widen_enums_with(child, canonical, aliases);
50+
}
51+
}
52+
serde_json::Value::Array(items) => {
53+
for item in items {
54+
widen_enums_with(item, canonical, aliases);
55+
}
56+
}
57+
_ => {}
58+
}
59+
}
60+
61+
const SERDE_FIELD_ALIASES: &[(&str, &str, &[&str])] = &[
62+
("BorderRadius", "top-left", &["top_left"]),
63+
("BorderRadius", "top-right", &["top_right"]),
64+
("BorderRadius", "bottom-right", &["bottom_right"]),
65+
("BorderRadius", "bottom-left", &["bottom_left"]),
66+
];
67+
68+
fn widen_properties_with(value: &mut serde_json::Value, canonical: &str, aliases: &[&str]) {
69+
match value {
70+
serde_json::Value::Object(map) => {
71+
if let Some(serde_json::Value::Object(properties)) = map.get_mut("properties") {
72+
if let Some(schema) = properties.get(canonical).cloned() {
73+
for alias in aliases {
74+
properties
75+
.entry((*alias).to_string())
76+
.or_insert_with(|| schema.clone());
77+
}
78+
}
79+
}
80+
for (key, child) in map.iter_mut() {
81+
if key != "properties" {
82+
widen_properties_with(child, canonical, aliases);
83+
}
84+
}
85+
}
86+
serde_json::Value::Array(items) => {
87+
for item in items {
88+
widen_properties_with(item, canonical, aliases);
89+
}
90+
}
91+
_ => {}
92+
}
93+
}
94+
95+
fn expose_serde_aliases(defs: &mut serde_json::Map<String, serde_json::Value>) {
96+
for (definition, canonical, aliases) in SERDE_ALIASES {
97+
if let Some(entry) = defs.get_mut(*definition) {
98+
widen_enums_with(entry, canonical, aliases);
99+
}
100+
}
101+
for (definition, canonical, aliases) in SERDE_FIELD_ALIASES {
102+
if let Some(entry) = defs.get_mut(*definition) {
103+
widen_properties_with(entry, canonical, aliases);
104+
}
105+
}
106+
}
107+
19108
fn build_schema() -> serde_json::Value {
20109
let mut scenario_schema = schema::generate_json_schema();
21110
let component_schema = serde_json::to_value(schemars::schema_for!(Component))
@@ -53,6 +142,7 @@ fn build_schema() -> serde_json::Value {
53142

54143
wrap_with_directives(defs_obj, "Component");
55144
wrap_with_directives(defs_obj, "ChildComponent");
145+
expose_serde_aliases(defs_obj);
56146

57147
if let Some(children) = scenario_schema.pointer_mut("/definitions/Scene/properties/children") {
58148
*children = serde_json::json!({
@@ -151,3 +241,118 @@ fn inject_for_each_use_definitions(defs_obj: &mut serde_json::Map<String, serde_
151241
}),
152242
);
153243
}
244+
245+
#[cfg(test)]
246+
mod serde_alias_exposure_tests {
247+
use super::*;
248+
249+
fn workspace_root() -> std::path::PathBuf {
250+
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
251+
.parent()
252+
.and_then(std::path::Path::parent)
253+
.expect("rustmotion is expected at <workspace>/crates/rustmotion")
254+
.to_path_buf()
255+
}
256+
257+
fn rust_sources(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
258+
let Ok(entries) = std::fs::read_dir(dir) else {
259+
return;
260+
};
261+
for entry in entries.flatten() {
262+
let path = entry.path();
263+
if path.is_dir() {
264+
if path.file_name().is_some_and(|n| n == "target") {
265+
continue;
266+
}
267+
rust_sources(&path, out);
268+
} else if path.extension().is_some_and(|e| e == "rs") {
269+
out.push(path);
270+
}
271+
}
272+
}
273+
274+
fn aliases_declared_in_the_sources() -> Vec<(String, String)> {
275+
let mut files = Vec::new();
276+
rust_sources(&workspace_root().join("crates"), &mut files);
277+
let mut found = Vec::new();
278+
for file in files {
279+
let Ok(text) = std::fs::read_to_string(&file) else {
280+
continue;
281+
};
282+
for line in text.lines() {
283+
let trimmed = line.trim_start();
284+
if !trimmed.starts_with("#[serde(") || !trimmed.contains("alias = \"") {
285+
continue;
286+
}
287+
let mut rest = trimmed;
288+
while let Some(at) = rest.find("alias = \"") {
289+
rest = &rest[at + "alias = \"".len()..];
290+
let Some(close) = rest.find('"') else { break };
291+
found.push((file.display().to_string(), rest[..close].to_string()));
292+
rest = &rest[close..];
293+
}
294+
}
295+
}
296+
found
297+
}
298+
299+
#[test]
300+
fn every_serde_alias_in_the_sources_is_reachable_from_the_exported_schema() {
301+
let declared = aliases_declared_in_the_sources();
302+
assert!(
303+
declared.len() >= 10,
304+
"test setup: the scanner found only {} aliases, so it is not reading the sources",
305+
declared.len()
306+
);
307+
308+
let schema = serde_json::to_string(&build_schema()).expect("schema serializes");
309+
let missing: Vec<String> = declared
310+
.iter()
311+
.filter(|(_, alias)| !schema.contains(&format!("\"{alias}\"")))
312+
.map(|(file, alias)| format!("{alias} (declared in {file})"))
313+
.collect();
314+
315+
assert!(
316+
missing.is_empty(),
317+
"schemars does not emit #[serde(alias = ...)], so the exported schema declares \
318+
invalid what the engine accepts. Add each of these to SERDE_ALIASES or \
319+
SERDE_FIELD_ALIASES in this file:\n {}",
320+
missing.join("\n ")
321+
);
322+
}
323+
324+
#[test]
325+
fn the_canonical_spelling_is_never_replaced_by_its_alias() {
326+
let schema = build_schema();
327+
let card_align = schema
328+
.pointer("/definitions/CardAlign/enum")
329+
.and_then(|v| v.as_array())
330+
.expect("CardAlign is an enum of strings");
331+
let values: Vec<&str> = card_align.iter().filter_map(|v| v.as_str()).collect();
332+
assert!(
333+
values.contains(&"start") && values.contains(&"flex-start"),
334+
"an alias is widened onto the canonical spelling, never swapped for it: {values:?}"
335+
);
336+
}
337+
338+
#[test]
339+
fn an_enum_that_shares_a_variant_name_but_not_its_aliases_is_left_alone() {
340+
let schema = build_schema();
341+
let text = serde_json::to_string(&schema).expect("schema serializes");
342+
assert!(
343+
text.contains("\"space-between\""),
344+
"CardJustify's own aliases are there"
345+
);
346+
let justify_content = schema
347+
.pointer("/definitions/JustifyContent/enum")
348+
.and_then(|v| v.as_array());
349+
if let Some(values) = justify_content {
350+
let values: Vec<&str> = values.iter().filter_map(|v| v.as_str()).collect();
351+
assert!(
352+
!values.contains(&"flex_start"),
353+
"the table is keyed by definition, not by value, so a different enum with a \
354+
variant of the same name must not inherit aliases it does not have: {values:?}"
355+
);
356+
}
357+
}
358+
}

0 commit comments

Comments
 (0)