Skip to main content

Module expand

Module expand 

Source
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-each repeats a template subtree once per element of an array, binding the current element’s fields (plus $index) into it.
  • use instantiates a named, reusable subtree declared once in a top-level components block, with props overrides — a factored-out component definition, the same relationship include has 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):

  1. Parse JSON.
  2. variables::apply_variables — resolves the file’s own config/$var.
  3. expand::expand_directives (this module) — resolves for-each/ use using the now-literal document, then removes components.
  4. Deserialize into Scenario.
  5. include::resolve_includes — splices in child files (each of which already went through steps 1-4 independently inside include::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 (if rows is a declared config variable of array type). for-each itself never has to know variables exist.
  • You cannot instantiate a component defined in an included file — not from the parent’s use sites, anyway. components is scoped to the document it is declared in, the same way config is: each document gets its own apply_variables + expand_directives pass over its own text before it is ever spliced into anything else. A use inside a file that includes another file cannot see the includee’s components, and a use inside 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 test use_cannot_reach_a_component_defined_in_a_sibling_included_file in rustmotion/tests/templates_iteration.rs for 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/use directive found in any children array anywhere in value, and consume the top-level components block (like variables::apply_variables consumes config, it is removed so it never reaches Scenario’s deny_unknown_fields). Call this once per document, immediately after variables::apply_variables and before deserializing into Scenario — see the module doc for why that ordering is load-bearing.