morph-ir: The Crate That Thinks
Part of: Dev Docs
If morph-parser (crate-parser) is the stenographer, morph-ir (crates/morph-ir/src/) is the analyst who reads the transcript and writes the battle plan. IRBuilder consumes every MxSource in the module graph plus the parsed CSS and produces IRWindow / IRNode trees: structure, resolved styles, reactive wiring, animations — everything codegen needs, nothing it doesn't. This crate is also where your friendly names die and qualified C++ expressions are born (count → app::<namespace>::count().get()), where Tailwind classes become numbers, and where event subscriptions get rewritten before anyone notices.
At 6,300+ lines, builder.rs is the largest single file in the Rust workspace. Do not read it top to bottom. Navigate by the map below.
The data (node.rs, style.rs): the plan's vocabulary
IRNode is one UI node, fully described: identity (node_id, node_type), three style buckets (style, hover_style, active_style), children, events (IREvent: trigger/action/target), text (text_content plus reactive_text for state-bound strings), attributes (attrs plus reactive_attrs / reactive_class / reactive_style for the dynamic halves), conditional branches (condition_expr + then_nodes / else_nodes), list rendering (list_expr, list_key_expr, item_template, list_item_param, list_index_param), animations (animations, hover_animations), geometry (x/y/w/h), and transitions (transition_duration, transition_easing).
IRWindow is the whole app: window chrome (id, title, size, min/max, visible, modal, renderer), the node forest, startup_logs, premain_functions / extra_headers (user code that rides along), and the binding contracts codegen trusts blindly:
| Field | Contract |
|---|---|
state_vars |
Component state slots |
shared_vars |
The complete list of morphShared signals to declare — missing here means no C++ emitted, no apology |
event_decls |
One entry per visible event binding: identity key, namespace ns, evt_<name> accessor (build TU emits one static Channel each; dev TU keeps the string registry) |
channel_subs |
Subscriptions as {channel, body} pairs — body is a [](const JsValue&) lambda only (the emitter adds the single .on() wrap) |
mid_assignments |
mid native-index assignments per tagged instance (MID_* constants) |
module_bindings |
Universal bindings (functions/vars/classes + re-export aliases) with defining namespaces |
cpp_imports, keyframes |
Native edges and animation data |
IRStyle is the computed-style value object: colors as [f32; 4], box fields (margin/padding as 4-arrays, margin_auto as 4-bools), sizing (width/height/min/max as Option<f32>), flexbox (flex_dir, grow/shrink/basis, gap, wrap, justify_content, align_items), positioning, borders, z_index, opacity, scrollbar fields, and transform state (transform_ops, composed transform_matrix, transform_origin). IRStyle::new() sets web-sane defaults (display: block, font_size: 16.0, opacity: 1.0, …); is_empty_style() detects untouched pseudo buckets via exact-equality field comparison (float == is intentional here — change detection, not math).
The registries (css_registry.rs, tailwind.rs)
css_registry.rs is the single source of truth for supported CSS: ~86 KNOWN_PROPERTIES, the CSS_TO_IR name map (background-color → bg_color, flex-direction → flex_dir, …), and property_feature mapping properties to MORPH_FEATURE_* flags (flex, radius, opacity, transform, animation, scroll, positioning). Unknown properties are rejected here, not deep in layout code.
tailwind.rs (TailwindResolver::resolve / resolve_many) lowers 500+ utility classes to style values with no Node.js involved: a static_map for the plain cases, resolve_negative for -mt-4-style negation (numeric negation, not string fiddling), parse_arbitrary for w-[37px]-style escapes (every prefix covered, pinned by tests), and transform_fn + negate_value for transform utilities. Test names tell you the guarantees: static_entries_cover_all_tiers, negative_utilities_negate_numerically, arbitrary_values_cover_every_prefix, unknown_and_malformed_classes_resolve_empty. If a Tailwind class silently does nothing, this file (or the class spelling) is the suspect — never the renderer.
The math (transforms.rs)
parse_transform turns a CSS transform list into Vec<TransformOp> (angles accepted in deg/rad/grad/turn, lengths with %-of-own-box support), and compose_transform folds the ops into a 4×4 matrix ([f32; 16]) with per-op composers (rotate_x/y/z, rotate_axis, skew_x/y, perspective, generic multiply) plus parse_transform_origin (keywords and percentages). The test suite checks parsing edge cases, rotation against real math, chain order (transforms compose left-to-right — get this backwards and everything shears), and parity with the old Python output (compose_matches_python_output). New transform functions land here as a build_op case plus a composer plus a test.
The builder (builder.rs): IRBuilder in stages
IRBuilder::new(graph) + build() / build_with_graph() drive the whole show. The stages, in order:
- Namespace validation (
validate_namespaces,ns_of): every module's C++ namespace must be unique and well-formed before anything else. Fail fast, fail clearly. - Sibling preseeding (
preseed_sibling_names): names visible across the module before per-module work begins. - Per-module, per-instance expansion (
expand_component_sources,expand_instance): components instantiate once per use site, inside anInstanceFrame(rootfor the entry frame,instancefor nested). Props bind here (bind_props), call-props adapt (translate_call_prop,synthesize_adapter),midconstants assign (validate_mid,mid_const_name,mid_assignments). - State/event seeding (
seed_module_bindings,seed_state_var,register_binding/register_module_binding/register_import/register_alias,register_event): the identity machinery from State & Event Internals — canonicalmodule::bindingkeys, per-module namespaces,frame.vars/frame.eventsambient maps, dedupedshared_varsentries. Import resolution threads through re-exports (seed_named_import,seed_default_import,resolve_through_reexports,seed_re_exports,resolve_module_path). - Event rewrite (
translate_event_sub,rewrite_event_emits+rewrite_emit_for_js/rewrite_emit_for_cpp,rewrite_emit_calls,rewrite_channel_access): the order-sensitive textual rewrites — emit →morphEmitplaceholder,.on(handler)→ lambda-only body with fresh__ch_Nparams. - Node building (
build_node/build_node_in): JSX →IRNode, with selector matching (match_selector_compound,match_sequence,match_selector_detailed,selector_specificity,split_selector_sequence,split_trailing_pseudo), property application (apply_css_prop,parse_flex_shorthand), UA defaults (ua_defaults,ua_hover_defaults,ua_active_defaults,apply_ua_defaults), dynamic classes (analyze_dynamic_class,string_branch,resolve_branch_classes), keyframes (convert_keyframes), and helpers for member validation (validate_member_names), event triggers (event_trigger), and type plumbing (ts_type_class,parse_fn_type,parse_callable_source,prop_zero_value,infer_state_type,strip_static_linkage). - Logic translation (
translate_logic,translate_module_globals,push_snippet): component logic lowered with the frame's maps, ready for codegen.
String surgery at this scale needs safe substitution primitives, and the builder has them: subst_props_refs, rename_symbols (identifier-aware via is_ident_start / is_ident_char_at), capture_raw, prepare_source, copy_template, plus call-arg splitting that respects nesting (split_call_args, find_emit_callee, split_top_level_commas, split_paren_body, find_top_level_arrow). If you add a rewrite, use these — hand-rolled substring replacement in this file is how double-.on() bugs are born.
The serializer (serializer.rs): one shape, two customers
to_dict / to_json convert windows (and an optional logic_so_path) into the JSON the dev runtime consumes: window, node, style, transform_op, animations, keyframes_dict, keyframe (with declared fields and raw values kept separate). Non-finite floats become null; unset optionals serialize as null; the shape is pinned by tests (node_shape_matches_dev_protocol, to_json_round_trips, keyframes_keep_only_declared_fields). The build pipeline and the hot-reload pipe (Dev Mode) consume the same bytes — one serialization, two destinations, zero drift.
Where to cut
| "I want to…" | Touch |
|---|---|
| Add an IR field | node.rs / style.rs + serializer.rs shape + both emitters' handling + shape tests |
| Support a CSS property end-to-end | css_registry.rs entry → apply_css_prop case → IRStyle field → serializer → node_emitter::set_style → property_feature flag |
| Add a Tailwind utility | tailwind.rs (static_map / negative / arbitrary) + tier-coverage test |
| Add a transform function | transforms.rs (build_op + composer + order test) |
| Change state/event identity | binding_identity, module_namespace, event_channel_id — and the codegen side that must agree |
| Change emit/on lowering | rewrite_event_emits / translate_event_sub — keep the lambda-only contract |
| Change instantiation or prop binding | expand_instance / bind_props — re-run all runtime fixtures |
Verify by
cargo test -p morph-ir
cargo test -p morph-parser -p morph-ir -p morph-codegen
cargo test --workspaceThe IR-shape tests are the review: any changed app::… accessor string means a relink of every store/event. Confirm the diff is the one you intended, then run morph check + morph build on tests/runtime/component-test and examples/components.