[00143] Add the Rusty IvyML Markup Crate That Compiles Declarative Layout Into Widget Trees - #110
Merged
rorychatt merged 7 commits intoAug 2, 2026
Conversation
Adds a fourth workspace crate exporting two function-like proc macros that
compile declarative markup into the builder chains that already exist in
rusty::widgets. No new runtime, no interpreter, no wire-format change: a
malformed tag is a rustc error with a span, not a panic in production.
Three design decisions came from prototype failures, each pinned by a comment
at the code that implements it:
- `&str` builder slots emit `&(expr)`, so an interpolated `String`, `&String`,
`&str` or `format!(..)` all coerce without `.as_str()` noise in the markup.
- `on_*` is its own argument class, checked *before* the per-attribute match, and
always passed by value. Falling through to the `&str` default borrows the
closure into a temporary that cannot satisfy the `'static` bound (E0716), so
the naive version compiles no handler at all.
- `ivyml_file!` emits `const _: &str = include_str!(..)`. A proc macro that reads
a file has no dependency edge to it and `cargo:rerun-if-changed` is
unavailable here, so without that line cargo serves a stale expansion.
Measured both ways in this worktree: with the guard removed, editing the
.ivyml and rebuilding printed the previous text.
`width`/`height` map to `Size` variants rather than bare numbers because `Size`
is `#[serde(untagged)]` — `Px(240.0)` and `Percent(240.0)` both serialize to
`240.0` and widgets emit `to_css()` by hand, so the variant must be chosen at
compile time for the right CSS to reach the client.
Element mapping is a per-element `Shape { ctor, child_method }` because Rusty's
constructors are not uniform: `<List>` attaches children with `.item()`, since
`List` stores `items` and has no `.child` method at all.
The variant enums (`TextVariant`, `ButtonVariant`, `BadgeVariant`) are not
re-exported from `rusty::widgets`, so lowering emits their defining-module paths
rather than adding re-exports — the crate's contract is to call existing code.
The macros are re-exported from `rusty` but deliberately not from `prelude`: a
glob-imported `ivyml!` reads as a locally defined macro.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ten tests in rusty/tests/ivyml.rs. They live in `rusty` rather than in `rusty-ivyml` because a proc-macro crate exports only macros and cannot expand them against `rusty`'s widgets, so there is nothing to assert from inside it. Four are equivalence tests: they compare the markup's serialized JSON against the hand-written builder chain a reviewer already trusts, so if lowering drifts the JSON stops matching rather than a hand-written shape assertion going stale. The rest pin the three non-obvious behaviours: - `size_literals_reach_the_wire_as_css_not_bare_numbers` asserts the CSS strings, which is the only way to observe the `Size` variant through `#[serde(untagged)]`. - `interpolated_string_expressions_coerce_into_str_slots` covers `format!(..)`, `String`, `&String` and `&str` in one markup block. - `handlers_register_and_dispatch_after_assign_ids` dispatches through the event registry. It is a compile-time guard as much as a runtime one: had `on_*` fallen through to the `&str` default, this file would not build (E0716). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds rusty-docs/docs/02_concepts/07_markup.md — the grammar, the element and
attribute tables, the .ivyml file form with its CARGO_MANIFEST_DIR-relative path,
and the nine diagnostics as they actually print. The numeric prefix sets author
order, so 07 follows the existing 01-06 pages.
Two things the page states explicitly because they are surprising rather than
incidental: .ivyml files must be Rust-lexable (that is what buys spans and
`{expr}`, and it rules out bare prose in child position), and markup and builders
are fully interchangeable via `{expr}` splices in either direction.
Also adds rusty-ivyml to the README's Crate Structure table. The Quick Start is
left on the builder form: that is the baseline API and both are supported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One conflict, in the workspace `members` list: main added `rusty-desktop` (via PR #103) at the same position this branch added `rusty-ivyml`. Both sides add a crate and neither replaces the other, so the resolution keeps both. Nothing else conflicted. Main also landed plan 00093, which rewrote widget internals to use the derive macro — the guardrail that kept this crate out of rusty/src/widgets/ is why that was a clean auto-merge. Re-checked after the merge: the variant enums (TextVariant, ButtonVariant, BadgeVariant) are still not re-exported from `rusty::widgets`, so codegen's defining-module paths are still the right form, and `List` still attaches children through `.item()`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Not this plan's change. `cargo fmt --all -- --check` — CI's `Format check` step — exits 1 on pristine `origin/main` @ 45dd7aa with exactly this diff, a doubled blank line after the `mod` declarations: Diff in ...\rusty-macros\src\lib.rs:5: mod hook_rules; mod widget_checks; - /// Derive macro for the `WidgetData` trait. Proof it is inherited, not mine: - `git rev-parse HEAD:rusty-macros/src/lib.rs` and `git rev-parse origin/main:rusty-macros/src/lib.rs` are the same blob (a8a8109), so no commit of this branch touched the file. - `git log <branch> --not origin/main -- rusty-macros/` is empty. - A detached `git worktree add origin/main` with its own CARGO_TARGET_DIR reproduces the failure at exit 1 on unmodified main. Introduced by 3d03c44, "[00093] Resolve merge conflicts with main". Merge commits never run the pre-commit hook and `main` has no branch protection, so the gap between the two `mod` lines and the doc comment survived a conflict resolution that would have been rejected on a normal commit. Fixed with `cargo fmt -p rusty-macros`, scoped to the one crate so this commit stays separable from the feature work. It unblocks this plan's RustFmt verification, which cannot otherwise report on the crate it does add. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Not this plan's change, and the same file and same source commit as 67e4cd8. CI's `Clippy` step exits 101 on pristine `origin/main` @ 45dd7aa: error: this `match` expression can be replaced with `?` --> rusty-macros\src\lib.rs:115:39 error: this `match` expression can be replaced with `?` --> rusty-macros\src\lib.rs:129:31 error: could not compile `rusty-macros` (lib) due to 2 previous errors Proven in the same detached `origin/main` probe worktree used for 67e4cd8, with its own CARGO_TARGET_DIR. The file's blob is byte-identical to main's (a8a8109) and no commit of this branch touches rusty-macros/, so it is inherited. It fails with and without `--no-default-features`, and with the workspace or the crate alone: four invocations, all exit 101. Both sites were `match expr { Ok(v) => v, Err(err) => return Err(err) }`, which is what `question_mark` fires on; replaced with `collect::<syn::Result<_>>()?`. Semantics are unchanged, and the derive macro's own suites confirm it: `cargo test -p rusty-macros` 51 passed (the trybuild UI suite) and `cargo test -p rusty --lib derive_tests` 14 passed. The sites are new rather than long-standing, which is why nobody caught them: `git show 6e7664c:rusty-macros/src/lib.rs | grep -c 'Err(err) => return Err(err)'` is 0, and at 45dd7aa it is 3. They arrived with plan 00093 (PR #104), whose merge resolution 3d03c44 also left the rustfmt defect that 67e4cd8 repairs. This plan's own clippy run at its original base 6e7664c was green for that reason. Merge commits never run the pre-commit hook and `main` has no branch protection, so both defects reached `main` unchecked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Inherited breakage, not this plan's: `every_widget_type_is_mapped` and `widget_type_scan_finds_known_widgets` both fail on pristine `origin/main` (reproduced at 45dd7aa and at e6c4398 in a detached probe worktree with its own CARGO_TARGET_DIR). The blob was byte-identical to main's before this commit. `widget_types_from_sources` derived its inventory by grepping the widgets for `"type": "..."` literals. Plan 00093's 66128e0 moved 12 widgets onto `#[derive(Widget)]`, which *generates* that literal, so the scan went blind to all 12 -- `button` among them -- and the count fell from 38 to 26. The panic message said what to do ("If the to_json `"type": "..."` convention changed, fix this scan"); this is that fix. The scan now reads both declaration styles: the literal for a hand-written to_json, and for a derive, the `#[widget(type = "...")]` override when present or the snake_cased struct name otherwise. Inventory is back to exactly 38, with 12 coming from the derive branch. Two things keep the same blind spot from reopening. The known-widgets control now names widgets from each style deliberately -- a scan that lost one branch would still satisfy `>= 38` on the other -- and a new unit test drives the derive parser off fixtures rather than the live tree, so a future migration cannot make that control vacuous. It also pins the cases the naive parse would get wrong: an intervening doc comment and `#[serde(..)]` attribute, and `WidgetData` in a derive list, which must not count as `Widget`. Fixing rather than only filing it was necessary: `cargo test --workspace` is a whole-workspace gate, so this plan could not report a real Pass while an unrelated module was red. Scoped to `rusty/src/shared/`, which the plan permits (its guardrails forbid `widgets/`, `views/` and `core/`) and touching only the `#[cfg(test)]` module -- no shipped code changed. Cargo.lock records cargo's own disambiguation of `syn` to `syn 2.0.117` for rusty-macros, now that two syn majors coexist in the tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rorychatt
deleted the
tendril/00143-AddTheRustyIvyMLMarkupCrateThatCompilesDeclarativeLayoutInto
branch
August 2, 2026 10:39
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
00143 — Add the Rusty IvyML Markup Crate
Adds a fifth workspace crate,
rusty-ivyml, exporting two function-like proc macrosthat lower declarative XML-ish markup into the builder chains Rusty already has:
ivyml_file!("src/views/dashboard.ivyml")does the same for external markup, resolvedagainst
CARGO_MANIFEST_DIRand compiled at build time. No new runtime, no interpreter,no wire-format change: a malformed tag is a
rustcerror with a span inside the markup,not a panic in production.
Commits
f66d0a9rusty-ivymlcrate (ast.rs,codegen.rs,lib.rs) plus workspace wiring and therustyre-export15882f9rusty/tests/ivyml.rs— 10 testsd22af2drusty-docs/docs/02_concepts/07_markup.mdand the README crate row3a34682origin/main(45dd7aa); resolve thememberscollision withrusty-desktop67e4cd8rusty-macros/src/lib.rs5d81694clippy::question_mark×2 inrusty-macros/src/lib.rs9fb4213widget_namesscan blinded by#[derive(Widget)]13 files, +1374 −35 against the merge-base.
Verifications
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --no-default-features -- -D warningscargo build --workspace --no-default-featurescargo test --workspace --no-default-features— 498 passed, 0 failedgit diff -- src/frontend/ e2e/is emptyEvery command was derived from
node scripts/ci-step.js build <step>rather than fromthe verification prompts, which still quote pre-
--no-default-featuresforms.Design decisions worth knowing
Three came from prototype failures recorded in the plan, and all three are load-bearing:
&strslots get&(expr). Most constructors take&str, and the common case iscontent={format!(..)}, which is aString. Deref coercion handles it; requiring.as_str()in markup would be noise on every interpolating line.on_*is checked before everything else and passed by value. Falling through tothe
&strdefault borrows the closure into a temporary that cannot satisfy the'staticbound (E0716). Handlers are the reason the markup is worth having, so thenaive ordering would not have compiled a single one.
ivyml_file!emitsconst _: &str = include_str!(..). A proc macro that reads afile has no dependency edge to it, and
cargo:rerun-if-changedis build-script-only.Without this line cargo serves a stale expansion — silently, exit 0. I verified this
in both directions rather than trusting the plan; see the four-run table in
Verification/CheckResult.md. The line looks removable and is not.Where I diverged from the plan, and why
1. Variant enums are addressed through their defining module. The plan's table
implies
variant="ghost"lowers torusty::widgets::ButtonVariant::Ghost.widgets/mod.rsre-exports the widget structs but not the variant enums, so that pathdoes not resolve (E0432/E0433). Adding the re-exports would have meant editing
rusty/src/widgets/, which the plan's guardrail forbids and which would have collidedwith plan 00093. So
codegen.rshas avariant_module/enum_pathpair emitting::rusty::widgets::button::ButtonVariantinstead. The documented markup surface isunchanged. Re-checked against the post-merge tree: the enums are still not re-exported.
2.
membersalso containsrusty-desktop. The plan quotes a four-cratemembersline;
rusty-desktoplanded on main mid-execution at exactly the position this planedits. Resolved keeping both.
3. Modules are longer than the prototype (
ast203 vs 142,codegen445 vs 303,lib119 vs 77). The growth is doc comments plus one real addition:parse_attr_nameuses
syn::ext::IdentExt::parse_anyso keyword attribute names (type,for) parserather than being rejected by
Ident::parse.4. Three inherited repairs, none of them this plan's code. All three landed via plan
00093's merge resolution
3d03c44/ commit66128e0, after this plan's original base6e7664c— which is why the PreExecution baseline was green. Each was proved inheritedbefore being touched: byte-identical blob to
origin/main, emptygit log --not origin/mainfor the path, and reproduction in a detached probe worktreeat pristine
origin/mainwith its ownCARGO_TARGET_DIR.I fixed rather than only filed them because
cargo fmt --all,cargo clippy --workspaceand
cargo test --workspaceare whole-workspace gates: this plan could not report atruthful Pass on the crate it adds while an unrelated crate was red. The third repair is
the substantive one — plan 00093 moved 12 widgets onto
#[derive(Widget)], whichgenerates the
"type": "..."literal thatwidget_names.rswas grepping for, so theinventory fell 38 → 26 and
buttonwent missing. The scan now reads both declarationstyles, and two new controls stop the same blind spot reopening.
mainis red right now oncargo test --workspaceate6c4398.9fb4213fixes it,but only when this plan merges.
What is deliberately not here
mapping needs — per-direction constructors, required-arg constructors, a non-child
container method (
List::item), and no-child leaves. The remainder is mechanical;filed as a recommendation, along with the three shapes that are genuinely not yet
covered (
Card/Dialogfooters,Tooltip's single boxed child,Field's positionalElementconstructor).trybuildcoverage of the nine diagnostics. Needs a new dev-dependency; filed.All nine were exercised by hand and produce the documented text with token-accurate
spans.
ivyml/ivyml_fileinrusty::prelude. Guardrail: a glob-importedivyml!readsas a locally defined macro, and the prelude exports only types.
Commits:
Created using Ivy Tendril.