Expand description
Data-driven repetition and reusable component templates.
This is the answer to the dominant failure mode the original audit named:
an LLM asked for “ten identical cards, different data” hand-writes ten
JSON subtrees, and every copy is a chance to diverge (a forgotten color,
a stray font-size, a position that doesn’t match its siblings). Two
directives close that gap, both usable inside any children array —
exactly where a component would go:
for-eachrepeats atemplatesubtree once per element of an array, binding the current element’s fields (plus$index) into it.useinstantiates a named, reusable subtree declared once in a top-levelcomponentsblock, withpropsoverrides — a factored-out component definition, the same relationshipincludehas to a whole scenario file, but within one file and without the I/O.
§Why this lives in rustmotion-core, not rustmotion
include.rs needs file/network I/O (std::fs, ureq), so it lives in
the rustmotion crate. This module is pure serde_json::Value rewriting
— no I/O, same as variables.rs — so it lives next to it here.
§Syntax, and why it looks like include/config rather than a third
dialect
{
"components": {
"stat_card": {
"params": {
"label": { "type": "string" },
"value": { "type": "number", "default": 0 },
"color": { "type": "string", "default": "#6366F1" }
},
"template": {
"type": "card",
"style": { "width": "300px", "background": "$color" },
"children": [
{ "type": "text", "content": "$label" },
{ "type": "counter", "value": "$value" }
]
}
}
},
"scenes": [{
"duration": 3.0,
"children": [
{
"for-each": "$rows",
"template": { "use": "stat_card", "props": { "label": "$label", "value": "$value" } }
}
]
}]
}components[name].params is deliberately the exact same shape as the
scenario-level config block ({"type": ..., "default": ..., "description": ...},
see crate::schema::VariableDefinition) — a param is a variable scoped
to one component instead of the whole file. use + its overrides field
mirrors IncludeDirective { include, config } (a name plus overrides) —
except the overrides field is called props, not config. That is
a deliberate, load-bearing difference, not inconsistency: [substitute]
(shared with variables.rs) skips recursing into any object key literally
named "config", so that the scenario-level config declarations block
(whose default values must stay literal, see
variables::test_config_key_not_substituted) is never accidentally
rewritten by whole-document substitution. Reusing that same key name for
use’s overrides would make a for-each binding ($label) placed inside
a nested use’s overrides silently never substitute — exactly the kind
of silent failure this workstream exists to remove. props sidesteps the
collision entirely while keeping the rest of the shape familiar.
For for-each, each array element’s own fields are bound directly (flat,
not $item.label): the codebase’s existing $name substitution has no
dotted-path support (see variables::parse_single_var_ref), so an element
{"label": "Revenue", "value": 120} exposes $label and $value
straight into the template, exactly like a config default would. The
whole element is also bound to $item (for forwarding it wholesale,
e.g. into a nested use’s props via {"$var": "item"}), and the
0-based position is bound to $index. Explicit data always wins: if an
element’s own field is named index or item, that value is kept and the
built-in is not inserted over it.
§Pass ordering (load-bearing, tested in
rustmotion/tests/templates_iteration.rs)
Every call site runs expand_directives immediately after
variables::apply_variables and before Scenario is deserialized —
same document, same pass boundary include sits on the other side of.
Concretely, per document (root scenario file, and independently for each
file pulled in by include, since components is file-local — see
below):
- Parse JSON.
variables::apply_variables— resolves the file’s ownconfig/$var.expand::expand_directives(this module) — resolvesfor-each/useusing the now-literal document, then removescomponents.- Deserialize into
Scenario. include::resolve_includes— splices in child files (each of which already went through steps 1-4 independently insideinclude::fetch_and_resolve).
Two consequences fall out of running expansion strictly after variable substitution and strictly per-document:
- You can iterate over an array that came from a variable.
"for-each": "$rows"is, by the time this module sees it, no longer a$-string — step 2 already replaced it with the literal array (ifrowsis a declaredconfigvariable of array type).for-eachitself never has to know variables exist. - You cannot instantiate a component defined in an included file —
not from the parent’s
usesites, anyway.componentsis scoped to the document it is declared in, the same wayconfigis: each document gets its ownapply_variables+expand_directivespass over its own text before it is ever spliced into anything else. Auseinside a file that includes another file cannot see the includee’scomponents, and auseinside the includee cannot see the includer’s. This is a deliberate simplicity choice (no cross-file component registry, no import syntax to design and version) — see the module testuse_cannot_reach_a_component_defined_in_a_sibling_included_fileinrustmotion/tests/templates_iteration.rsfor the resulting diagnostic.
§The index-shift trap (already drew blood once — see PR #145 / #160)
include has the exact same shape of bug this module could reintroduce:
a directive that expands to a scene count other than 1 shifts every
later views[V].scenes[S] index, and --fix patches the raw JSON by
that same indexed path. for-each is strictly worse on this axis — ten
elements shift nine siblings, not (at most) a handful. This module does
not try to solve that by tracking pre/post-expansion index maps: it
solves it the way include already does, by removing the temptation.
expand_directives runs before Scenario is deserialized, so
LoadedScenario::raw (what --fix would serialize) is already the
expanded tree by the time commands/validate.rs sees it — same as
include’s resolved scenes are already spliced into raw by the time
--fix runs. commands/validate.rs::refuse_fix is extended with a
UsesTemplateDirectives case, detected the same (raw-substring,
conservative-by-design) way UsesInclude already is, so --fix refuses
outright rather than writing the expansion back over the author’s
for-each/use/components source.
Functions§
- expand_
directives - Expand every
for-each/usedirective found in anychildrenarray anywhere invalue, and consume the top-levelcomponentsblock (likevariables::apply_variablesconsumesconfig, it is removed so it never reachesScenario’sdeny_unknown_fields). Call this once per document, immediately aftervariables::apply_variablesand before deserializing intoScenario— see the module doc for why that ordering is load-bearing.