Skip to content

[00143] Add the Rusty IvyML Markup Crate That Compiles Declarative Layout Into Widget Trees - #110

Merged
rorychatt merged 7 commits into
mainfrom
tendril/00143-AddTheRustyIvyMLMarkupCrateThatCompilesDeclarativeLayoutInto
Aug 2, 2026
Merged

[00143] Add the Rusty IvyML Markup Crate That Compiles Declarative Layout Into Widget Trees#110
rorychatt merged 7 commits into
mainfrom
tendril/00143-AddTheRustyIvyMLMarkupCrateThatCompilesDeclarativeLayoutInto

Conversation

@rorychatt

Copy link
Copy Markdown
Contributor

00143 — Add the Rusty IvyML Markup Crate

Adds a fifth workspace crate, rusty-ivyml, exporting two function-like proc macros
that lower declarative XML-ish markup into the builder chains Rusty already has:

ivyml! {
    <Layout direction="vertical" gap=16 padding=24>
        <TextBlock content="Hello, World!" variant="heading1" />
        <Card>
            <TextBlock content={format!("count = {}", n)} />
            <Button title="Inc" on_click={move || count.update(|v| v + 1)} />
        </Card>
    </Layout>
}

ivyml_file!("src/views/dashboard.ivyml") does the same for external markup, resolved
against CARGO_MANIFEST_DIR and compiled at build time. No new runtime, no interpreter,
no wire-format change: a malformed tag is a rustc error with a span inside the markup,
not a panic in production.

Commits

Hash What
f66d0a9 The rusty-ivyml crate (ast.rs, codegen.rs, lib.rs) plus workspace wiring and the rusty re-export
15882f9 rusty/tests/ivyml.rs — 10 tests
d22af2d rusty-docs/docs/02_concepts/07_markup.md and the README crate row
3a34682 Merge origin/main (45dd7aa); resolve the members collision with rusty-desktop
67e4cd8 Inherited repair — rustfmt failure in rusty-macros/src/lib.rs
5d81694 Inherited repairclippy::question_mark ×2 in rusty-macros/src/lib.rs
9fb4213 Inherited repairwidget_names scan blinded by #[derive(Widget)]

13 files, +1374 −35 against the merge-base.

Verifications

Gate Result Command actually run
RustFmt Pass cargo fmt --all -- --check
RustClippy Pass cargo clippy --workspace --all-targets --no-default-features -- -D warnings
RustBuild Pass cargo build --workspace --no-default-features
RustTest Pass cargo test --workspace --no-default-features498 passed, 0 failed
CheckResult Pass every deliverable enumerated; diagnostics and staleness guard exercised by hand
RustyFrontend{Lint,Build,Test} Skipped Rust-only change; git diff -- src/frontend/ e2e/ is empty

Every command was derived from node scripts/ci-step.js build <step> rather than from
the verification prompts, which still quote pre---no-default-features forms.

Design decisions worth knowing

Three came from prototype failures recorded in the plan, and all three are load-bearing:

  1. &str slots get &(expr). Most constructors take &str, and the common case is
    content={format!(..)}, which is a String. Deref coercion handles it; requiring
    .as_str() in markup would be noise on every interpolating line.
  2. on_* is checked before everything else and passed by value. Falling through to
    the &str default borrows the closure into a temporary that cannot satisfy the
    'static bound (E0716). Handlers are the reason the markup is worth having, so the
    naive ordering would not have compiled a single one.
  3. 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 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 to rusty::widgets::ButtonVariant::Ghost.
widgets/mod.rs re-exports the widget structs but not the variant enums, so that path
does 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 collided
with plan 00093. So codegen.rs has a variant_module/enum_path pair emitting
::rusty::widgets::button::ButtonVariant instead. The documented markup surface is
unchanged. Re-checked against the post-merge tree: the enums are still not re-exported.

2. members also contains rusty-desktop. The plan quotes a four-crate members
line; rusty-desktop landed on main mid-execution at exactly the position this plan
edits. Resolved keeping both.

3. Modules are longer than the prototype (ast 203 vs 142, codegen 445 vs 303,
lib 119 vs 77). The growth is doc comments plus one real addition: parse_attr_name
uses syn::ext::IdentExt::parse_any so keyword attribute names (type, for) parse
rather 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 / commit 66128e0, after this plan's original base
6e7664c — which is why the PreExecution baseline was green. Each was proved inherited
before being touched: byte-identical blob to origin/main, empty
git log --not origin/main for the path, and reproduction in a detached probe worktree
at pristine origin/main with its own CARGO_TARGET_DIR.

I fixed rather than only filed them because cargo fmt --all, cargo clippy --workspace
and cargo test --workspace are whole-workspace gates: this plan could not report a
truthful 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)], which
generates the "type": "..." literal that widget_names.rs was grepping for, so the
inventory fell 38 → 26 and button went missing. The scan now reads both declaration
styles, and two new controls stop the same blind spot reopening.

main is red right now on cargo test --workspace at e6c4398. 9fb4213 fixes it,
but only when this plan merges.

What is deliberately not here

  • The other ~25 widgets. The ten shipped elements cover every structural shape the
    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/Dialog footers, Tooltip's single boxed child, Field's positional
    Element constructor).
  • trybuild coverage 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_file in rusty::prelude. Guardrail: a glob-imported ivyml! reads
    as a locally defined macro, and the prelude exports only types.

Commits:

  • 9fb4213 [00143] Teach the widget_names scan to see derive-generated wire names
  • 5d81694 Repair the inherited clippy failure in rusty-macros/src/lib.rs
  • 67e4cd8 Repair the inherited rustfmt failure in rusty-macros/src/lib.rs
  • 3a34682 [00143] Resolve merge conflicts with main
  • d22af2d [00143] Document the markup layer
  • 15882f9 [00143] Add the ivyml markup test suite
  • f66d0a9 [00143] Add the rusty-ivyml crate with the ivyml! and ivyml_file! macros

Created using Ivy Tendril.

rorychatt and others added 7 commits August 2, 2026 12:02
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 rorychatt self-assigned this Aug 2, 2026
@rorychatt
rorychatt merged commit 9b7c764 into main Aug 2, 2026
@rorychatt
rorychatt deleted the tendril/00143-AddTheRustyIvyMLMarkupCrateThatCompilesDeclarativeLayoutInto branch August 2, 2026 10:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant