diff --git a/Cargo.lock b/Cargo.lock
index dad4778..f0f6bd5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2106,6 +2106,7 @@ dependencies = [
"axum",
"bytes",
"futures",
+ "rusty-ivyml",
"rusty-macros",
"serde",
"serde_json",
@@ -2142,6 +2143,15 @@ dependencies = [
"tracing-subscriber",
]
+[[package]]
+name = "rusty-ivyml"
+version = "0.1.0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "rusty-macros"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index efd17f9..dd8d5b1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
[workspace]
-members = ["rusty", "rusty-macros", "rusty-server", "rusty-docs", "rusty-desktop"]
+members = ["rusty", "rusty-macros", "rusty-ivyml", "rusty-server", "rusty-docs", "rusty-desktop"]
resolver = "2"
[workspace.package]
diff --git a/README.md b/README.md
index 425e06b..69a4234 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,7 @@ Rusty-Framework follows the same architecture as Ivy-Framework:
|-------|-------------|
| `rusty` | Core framework — views, hooks, widgets, server, shared types |
| `rusty-macros` | Proc macros for `#[derive(Widget)]`, `#[prop]`, `#[event]` |
+| `rusty-ivyml` | Proc macros for `ivyml!` / `ivyml_file!` declarative markup |
| `rusty-server` | Standalone server binary |
## Examples
diff --git a/rusty-docs/docs/02_concepts/07_markup.md b/rusty-docs/docs/02_concepts/07_markup.md
new file mode 100644
index 0000000..7a78ad8
--- /dev/null
+++ b/rusty-docs/docs/02_concepts/07_markup.md
@@ -0,0 +1,168 @@
+## Markup
+
+`ivyml!` compiles declarative markup into the same builder chains you would write
+by hand. There is no runtime, no interpreter and no wire-format change: the macro
+expands to `Layout::vertical().gap(16.0).child(..)`, so a malformed tag is a
+`rustc` error with a span rather than a panic in production.
+
+```rust
+use rusty::ivyml;
+
+impl View for HelloApp {
+ fn build(&self, _ctx: &mut BuildContext) -> Element {
+ ivyml! {
+
+
+
+
+ }
+ }
+}
+```
+
+`ivyml` is exported from the crate root, not from the prelude — a glob-imported
+`ivyml!` reads as a locally defined macro.
+
+### Grammar
+
+- **One root element per macro.** Wrap siblings in a container.
+- **Attributes** are `name=literal` or `name={rust_expr}`.
+- **Children** are nested elements or `{expr}` splices.
+- Both ` ` and ` ` parse; a mismatched closing tag is an error.
+
+### Elements
+
+Each tag maps to a constructor, because Rusty's constructors are not uniform and
+cannot be derived from the tag name:
+
+| Element | Constructor | Children attach via |
+|---------|-------------|---------------------|
+| `` | `Layout::vertical()` | `.child()` |
+| `` | `Layout::horizontal()` | `.child()` |
+| `` | `Layout::grid(3)` | `.child()` |
+| `` | `TextBlock::new("x")` | — (error if given children) |
+| `` | `Button::new("x")` | — |
+| `` | `Card::new()` | `.child()` |
+| `` | `Container::new()` | `.child()` |
+| `` | `List::new()` | **`.item()`** |
+| `` | `ListItem::new("x")` | — |
+| `` | `Badge::new("x")` | — |
+| `` | `TextInput::new()` | — |
+| `` | `Spacer::new()` | — |
+
+`` is why children attach through a per-element method: `List` stores
+`items`, not `children`, and has no `.child` method at all.
+
+The attributes a constructor consumes (`direction`, `columns`, `content`, `title`,
+`label`) are not also emitted as builder calls. Every other attribute becomes
+`.name(arg)`, with `-` mapped to `_`.
+
+### Attribute values
+
+Literals are coerced per slot so the markup stays free of Rust type noise:
+
+| Attribute | Markup | Emitted |
+|-----------|--------|---------|
+| `gap`, `padding`, `min`, `max`, `step` | `gap=16` | `16f64` |
+| `columns` | `columns=3` | `3usize` |
+| `disabled`, `loading`, `wrap`, `border`, `rounded` | `disabled=true` | `true` |
+| `width`, `height` | `width="100%"` / `"240px"` / `"auto"` | `Size::Percent(100.0)` / `Size::Px(240.0)` / `Size::Auto` |
+| `align`, `justify` | `justify="space-between"` | `Justify::SpaceBetween` |
+| `variant` on `` | `variant="ghost"` | `ButtonVariant::Ghost` |
+| `variant` on `` | `variant="heading1"` | `TextVariant::Heading1` |
+| `on_*` | `on_click={\|\| ..}` | the closure, by value |
+| anything else | `content="x"` | `"x"`, or `&(expr)` for `{expr}` |
+
+Enum-valued attributes accept both kebab and snake spelling: `"space-between"`
+and `"space_between"` both reach `Justify::SpaceBetween`.
+
+`width`/`height` map to `Size` variants rather than to bare numbers because `Size`
+is `#[serde(untagged)]` — `Px(240.0)` and `Percent(240.0)` both serialize to
+`240.0`, and widgets emit `Size::to_css()` by hand. Choosing the variant at
+compile time is what makes `"240px"` reach the client as `240px`.
+
+### Interpolation
+
+`{expr}` works in both attribute and child position. `&str` slots emit `&(expr)`,
+so an interpolated `String`, `&String`, `&str` or `format!(..)` all work without
+`.as_str()`:
+
+```rust
+ivyml! {
+
+
+
+ {existing_element}
+
+}
+```
+
+Event handlers are passed by value, never borrowed — `on_click={|| ..}` needs a
+`'static` closure, and a borrowed temporary cannot satisfy that. A literal in an
+`on_*` slot is an error.
+
+Markup and builders are fully interchangeable. A `{expr}` splice accepts anything
+that converts into an `Element`, including a builder chain, so you can drop into
+builders for a subtree and back out again:
+
+```rust
+let rows = items
+ .iter()
+ .map(|i| ListItem::new(&i.name).into())
+ .collect::>();
+
+ivyml! {
+
+
+ {List::new().items(rows)}
+
+}
+```
+
+### Markup in a separate file
+
+`ivyml_file!` compiles an external `.ivyml` file at build time. The path resolves
+against `CARGO_MANIFEST_DIR`, so it is relative to the crate root rather than to
+the source file:
+
+```rust
+use rusty::ivyml_file;
+
+impl View for Dashboard {
+ fn build(&self, _ctx: &mut BuildContext) -> Element {
+ ivyml_file!("src/views/dashboard.ivyml")
+ }
+}
+```
+
+```ivyml
+
+
+
+
+
+
+
+
+```
+
+`.ivyml` files reach the same parser as the inline form, which is what gives them
+`{expr}` interpolation and per-token spans. The cost is that they must be
+**Rust-lexable**: bare prose in child position does not lex, so use
+`content="..."`.
+
+### Diagnostics
+
+Errors point at the offending token, not at the macro call site:
+
+```text
+error: unknown IvyML element ``
+error: closing tag `
` does not match ``
+error: unknown direction `sideways`; expected vertical, horizontal or grid
+error: `` requires `title`
+error: expected a f64 literal here
+error: `` does not accept children
+error: `20em` is not a size; use `200px`, `50%` or `auto`
+error: expected a single root element; wrap siblings in a container such as
+error: an event handler must be an interpolated closure, e.g. on_click={|| ..}
+```
diff --git a/rusty-ivyml/Cargo.toml b/rusty-ivyml/Cargo.toml
new file mode 100644
index 0000000..c1b9918
--- /dev/null
+++ b/rusty-ivyml/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "rusty-ivyml"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+description = "Compiles .ivyml declarative markup into Rusty widget trees"
+
+[lib]
+proc-macro = true
+
+[dependencies]
+syn = { version = "2", features = ["full"] }
+quote = "1"
+proc-macro2 = "1"
diff --git a/rusty-ivyml/src/ast.rs b/rusty-ivyml/src/ast.rs
new file mode 100644
index 0000000..9786b72
--- /dev/null
+++ b/rusty-ivyml/src/ast.rs
@@ -0,0 +1,203 @@
+//! The IvyML grammar, parsed with `syn` at the token level.
+//!
+//! Parsing tokens rather than a string is what makes `{expr}` interpolation and
+//! span-accurate errors possible: rustc hands a proc macro real tokens, and
+//! `ivyml_file!` reaches this same code path by lexing the file text with
+//! [`str::parse::`]. The consequence is that `.ivyml` files must be
+//! Rust-lexable, which rules out bare prose in child position — use
+//! `content="..."` instead.
+
+use proc_macro2::{Span, TokenStream};
+use quote::ToTokens;
+use syn::parse::{Parse, ParseStream};
+use syn::{braced, Expr, Ident, Lit, Token};
+
+/// A single attribute on an element: `name=literal` or `name={rust_expr}`.
+pub struct Attribute {
+ pub name: Ident,
+ pub value: AttrValue,
+}
+
+/// The right-hand side of an attribute.
+pub enum AttrValue {
+ /// A bare literal, e.g. `gap=16` or `content="Hello"`.
+ Literal(Lit),
+ /// A braced Rust expression, e.g. `on_click={move || ..}`.
+ Expr(Expr),
+}
+
+impl AttrValue {
+ /// The span to point diagnostics at.
+ pub fn span(&self) -> Span {
+ match self {
+ AttrValue::Literal(lit) => lit.span(),
+ AttrValue::Expr(expr) => syn::spanned::Spanned::span(expr),
+ }
+ }
+}
+
+/// Anything that can appear in child position.
+pub enum Node {
+ Element(ElementNode),
+ /// A `{expr}` splice: any expression that converts into an `Element`.
+ Expr(Expr),
+}
+
+/// One `` element, self-closing or paired.
+pub struct ElementNode {
+ pub name: Ident,
+ pub attrs: Vec,
+ pub children: Vec,
+}
+
+impl ElementNode {
+ /// Look up an attribute by name.
+ pub fn attr(&self, name: &str) -> Option<&Attribute> {
+ self.attrs.iter().find(|a| a.name == name)
+ }
+}
+
+/// The whole macro body: exactly one root element.
+pub struct Markup {
+ pub root: ElementNode,
+}
+
+impl Parse for Attribute {
+ fn parse(input: ParseStream) -> syn::Result {
+ let name = parse_attr_name(input)?;
+ input.parse::()?;
+
+ let value = if input.peek(syn::token::Brace) {
+ let content;
+ braced!(content in input);
+ AttrValue::Expr(content.parse::()?)
+ } else {
+ AttrValue::Literal(input.parse::()?)
+ };
+
+ Ok(Attribute { name, value })
+ }
+}
+
+/// Attribute names may collide with Rust keywords (`type`, `for`), so accept any
+/// identifier rather than `Ident::parse`, which rejects keywords.
+fn parse_attr_name(input: ParseStream) -> syn::Result {
+ input.call(syn::ext::IdentExt::parse_any)
+}
+
+impl Parse for ElementNode {
+ fn parse(input: ParseStream) -> syn::Result {
+ input.parse::()?;
+ let name = parse_element_name(input)?;
+
+ let mut attrs = Vec::new();
+ while !input.peek(Token![>]) && !input.peek(Token![/]) {
+ attrs.push(input.parse::()?);
+ }
+
+ // ` ` — self-closing, no children.
+ if input.peek(Token![/]) {
+ input.parse::()?;
+ input.parse::]>()?;
+ return Ok(ElementNode {
+ name,
+ attrs,
+ children: Vec::new(),
+ });
+ }
+
+ input.parse::]>()?;
+
+ let mut children = Vec::new();
+ loop {
+ if input.is_empty() {
+ return Err(syn::Error::new(
+ name.span(),
+ format!("unclosed element `<{}>`", name),
+ ));
+ }
+
+ // A `` starts the closing tag; anything else is another child.
+ if input.peek(Token![<]) && input.peek2(Token![/]) {
+ break;
+ }
+
+ if input.peek(syn::token::Brace) {
+ let content;
+ braced!(content in input);
+ children.push(Node::Expr(content.parse::()?));
+ } else if input.peek(Token![<]) {
+ children.push(Node::Element(input.parse::()?));
+ } else {
+ return Err(syn::Error::new(
+ input.span(),
+ "expected a nested element `` or an interpolated \
+ expression `{expr}` here",
+ ));
+ }
+ }
+
+ input.parse::()?;
+ input.parse::()?;
+ let closing = parse_element_name(input)?;
+ input.parse::]>()?;
+
+ if closing != name {
+ return Err(syn::Error::new(
+ closing.span(),
+ format!("closing tag `{}>` does not match `<{}>`", closing, name),
+ ));
+ }
+
+ Ok(ElementNode {
+ name,
+ attrs,
+ children,
+ })
+ }
+}
+
+/// Element names carry an optional `.method` suffix (e.g. `` were it
+/// ever needed); today only the bare form is accepted, and rejecting the dotted
+/// form here rather than in codegen keeps the error at the tag.
+fn parse_element_name(input: ParseStream) -> syn::Result {
+ let name = input.parse::()?;
+ if input.peek(Token![.]) {
+ return Err(syn::Error::new(
+ name.span(),
+ "dotted element names are not supported; use a plain tag such as ``",
+ ));
+ }
+ Ok(name)
+}
+
+impl Parse for Markup {
+ fn parse(input: ParseStream) -> syn::Result {
+ if input.is_empty() {
+ return Err(syn::Error::new(
+ Span::call_site(),
+ "expected a single root element, e.g. ... ",
+ ));
+ }
+
+ let root = input.parse::()?;
+
+ if !input.is_empty() {
+ return Err(syn::Error::new(
+ input.span(),
+ "expected a single root element; wrap siblings in a container such as ",
+ ));
+ }
+
+ Ok(Markup { root })
+ }
+}
+
+impl ToTokens for AttrValue {
+ fn to_tokens(&self, tokens: &mut TokenStream) {
+ match self {
+ AttrValue::Literal(lit) => lit.to_tokens(tokens),
+ AttrValue::Expr(expr) => expr.to_tokens(tokens),
+ }
+ }
+}
diff --git a/rusty-ivyml/src/codegen.rs b/rusty-ivyml/src/codegen.rs
new file mode 100644
index 0000000..1c492a3
--- /dev/null
+++ b/rusty-ivyml/src/codegen.rs
@@ -0,0 +1,445 @@
+//! Lowering from the [`crate::ast`] tree to the builder chains that already
+//! exist in `rusty::widgets`.
+//!
+//! There is no runtime here and no new wire format: ``
+//! becomes `Layout::vertical()`, and every attribute becomes a builder call on it.
+
+use proc_macro2::{Span, TokenStream};
+use quote::{format_ident, quote};
+use syn::{Ident, Lit};
+
+use crate::ast::{AttrValue, Attribute, ElementNode, Node};
+
+/// How one element name maps onto Rusty's builders.
+///
+/// The mapping cannot be derived from the tag name: the constructors are not
+/// uniform (`Layout::vertical()` vs `TextBlock::new(content)` vs `Card::new()`),
+/// and `List` attaches children with `.item()` because it stores `items`, not
+/// `children`, and has no `.child` method at all.
+struct Shape {
+ /// The constructor call, already complete.
+ ctor: TokenStream,
+ /// The builder method children attach through, or `None` for a leaf.
+ child_method: Option,
+ /// Attributes the constructor consumed, which must not also be emitted as
+ /// builder calls.
+ consumed_by_ctor: Vec<&'static str>,
+}
+
+/// Lower one element to its builder chain.
+pub fn element_tokens(el: &ElementNode) -> syn::Result {
+ let shape = shape_for(el)?;
+
+ let mut chain = shape.ctor;
+
+ for attr in &el.attrs {
+ let name = attr.name.to_string();
+ if shape.consumed_by_ctor.contains(&name.as_str()) {
+ continue;
+ }
+ let method = format_ident!("{}", name.replace('-', "_"), span = attr.name.span());
+ let arg = coerce(&el.name, &name, &attr.value)?;
+ chain = quote! { #chain.#method(#arg) };
+ }
+
+ if el.children.is_empty() {
+ return Ok(chain);
+ }
+
+ let Some(child_method) = shape.child_method else {
+ return Err(syn::Error::new(
+ el.name.span(),
+ format!("`<{}>` does not accept children", el.name),
+ ));
+ };
+
+ for child in &el.children {
+ let child_tokens = match child {
+ Node::Element(nested) => element_tokens(nested)?,
+ Node::Expr(expr) => quote! { #expr },
+ };
+ chain = quote! { #chain.#child_method(#child_tokens) };
+ }
+
+ Ok(chain)
+}
+
+/// Resolve the element name to its [`Shape`], validating required attributes.
+fn shape_for(el: &ElementNode) -> syn::Result {
+ let name = el.name.to_string();
+ let span = el.name.span();
+ let ty = &el.name;
+
+ let shape = match name.as_str() {
+ "Layout" => {
+ let direction = el.attr("direction");
+ let ctor = match direction {
+ None => quote! { ::rusty::widgets::Layout::vertical() },
+ Some(attr) => {
+ let value = string_literal(attr, "direction")?;
+ match value.as_str() {
+ "vertical" => quote! { ::rusty::widgets::Layout::vertical() },
+ "horizontal" => quote! { ::rusty::widgets::Layout::horizontal() },
+ "grid" => {
+ let columns = el.attr("columns").ok_or_else(|| {
+ syn::Error::new(
+ span,
+ "`` requires `columns`",
+ )
+ })?;
+ let columns = coerce(ty, "columns", &columns.value)?;
+ quote! { ::rusty::widgets::Layout::grid(#columns) }
+ }
+ other => {
+ let msg = format!(
+ "unknown direction `{}`; expected vertical, horizontal or grid",
+ other
+ );
+ return Err(syn::Error::new(attr.value.span(), msg));
+ }
+ }
+ }
+ };
+ Shape {
+ ctor,
+ child_method: Some(child_ident(span)),
+ consumed_by_ctor: vec!["direction", "columns"],
+ }
+ }
+ "TextBlock" => Shape {
+ ctor: required_str_ctor(el, "content")?,
+ child_method: None,
+ consumed_by_ctor: vec!["content"],
+ },
+ "Button" => Shape {
+ ctor: required_str_ctor(el, "title")?,
+ child_method: None,
+ consumed_by_ctor: vec!["title"],
+ },
+ "ListItem" => Shape {
+ ctor: required_str_ctor(el, "title")?,
+ child_method: None,
+ consumed_by_ctor: vec!["title"],
+ },
+ "Badge" => Shape {
+ ctor: required_str_ctor(el, "label")?,
+ child_method: None,
+ consumed_by_ctor: vec!["label"],
+ },
+ "Card" => Shape {
+ ctor: quote! { ::rusty::widgets::Card::new() },
+ child_method: Some(child_ident(span)),
+ consumed_by_ctor: Vec::new(),
+ },
+ "Container" => Shape {
+ ctor: quote! { ::rusty::widgets::Container::new() },
+ child_method: Some(child_ident(span)),
+ consumed_by_ctor: Vec::new(),
+ },
+ "List" => Shape {
+ // `List` stores `items`, which is why `child_method` is per-element.
+ ctor: quote! { ::rusty::widgets::List::new() },
+ child_method: Some(Ident::new("item", span)),
+ consumed_by_ctor: Vec::new(),
+ },
+ "TextInput" => Shape {
+ ctor: quote! { ::rusty::widgets::TextInput::new() },
+ child_method: None,
+ consumed_by_ctor: Vec::new(),
+ },
+ "Spacer" => Shape {
+ ctor: quote! { ::rusty::widgets::Spacer::new() },
+ child_method: None,
+ consumed_by_ctor: Vec::new(),
+ },
+ other => {
+ let msg = format!("unknown IvyML element `<{}>`", other);
+ return Err(syn::Error::new(span, msg));
+ }
+ };
+
+ Ok(shape)
+}
+
+fn child_ident(span: Span) -> Ident {
+ Ident::new("child", span)
+}
+
+/// Build a `Type::new(arg)` constructor from a required `&str` attribute.
+fn required_str_ctor(el: &ElementNode, attr_name: &str) -> syn::Result {
+ let attr = el.attr(attr_name).ok_or_else(|| {
+ let msg = format!("`<{}>` requires `{}`", el.name, attr_name);
+ syn::Error::new(el.name.span(), msg)
+ })?;
+ let ty = &el.name;
+ let arg = str_arg(&attr.value);
+ Ok(quote! { ::rusty::widgets::#ty::new(#arg) })
+}
+
+/// Coerce an attribute value to the argument the builder slot expects.
+///
+/// The `on_` prefix is checked **first**: an event handler must be passed by
+/// value, and falling through to the `&str` default below borrows the closure
+/// into a temporary that cannot satisfy the `'static` bound (`E0716`).
+fn coerce(element: &Ident, attr: &str, value: &AttrValue) -> syn::Result {
+ if attr.starts_with("on_") {
+ return match value {
+ AttrValue::Expr(expr) => Ok(quote! { #expr }),
+ AttrValue::Literal(lit) => Err(syn::Error::new(
+ lit.span(),
+ "an event handler must be an interpolated closure, e.g. on_click={|| ..}",
+ )),
+ };
+ }
+
+ match attr {
+ "gap" | "padding" | "value" | "min" | "max" | "step" if !is_text_value(element, attr) => {
+ float_arg(value)
+ }
+ "columns" => usize_arg(value),
+ "disabled" | "loading" | "wrap" | "border" | "rounded" | "read_only" => bool_arg(value),
+ "width" | "height" => size_arg(value),
+ "align" => enum_arg(value, "Align", &ALIGN_VARIANTS),
+ "justify" => enum_arg(value, "Justify", &JUSTIFY_VARIANTS),
+ "variant" => variant_arg(element, value),
+ _ => Ok(str_arg(value)),
+ }
+}
+
+/// `` takes a `&str`, unlike ``.
+fn is_text_value(element: &Ident, attr: &str) -> bool {
+ attr == "value" && element == "TextInput"
+}
+
+/// Emit `&(expr)` for `&str` slots so an interpolated `String`, `&String` or
+/// `format!(..)` all coerce. Requiring `.as_str()` inside markup would put type
+/// noise on every line that interpolates.
+fn str_arg(value: &AttrValue) -> TokenStream {
+ match value {
+ AttrValue::Literal(lit) => quote! { #lit },
+ AttrValue::Expr(expr) => quote! { &(#expr) },
+ }
+}
+
+fn float_arg(value: &AttrValue) -> syn::Result {
+ match value {
+ AttrValue::Literal(Lit::Int(int)) => {
+ let v = int.base10_parse::()? as f64;
+ Ok(quote! { #v })
+ }
+ AttrValue::Literal(Lit::Float(float)) => {
+ let v = float.base10_parse::()?;
+ Ok(quote! { #v })
+ }
+ AttrValue::Literal(other) => {
+ Err(syn::Error::new(other.span(), "expected a f64 literal here"))
+ }
+ AttrValue::Expr(expr) => Ok(quote! { #expr }),
+ }
+}
+
+fn usize_arg(value: &AttrValue) -> syn::Result {
+ match value {
+ AttrValue::Literal(Lit::Int(int)) => {
+ let v = int.base10_parse::()?;
+ Ok(quote! { #v })
+ }
+ AttrValue::Literal(other) => Err(syn::Error::new(
+ other.span(),
+ "expected a usize literal here",
+ )),
+ AttrValue::Expr(expr) => Ok(quote! { #expr }),
+ }
+}
+
+fn bool_arg(value: &AttrValue) -> syn::Result {
+ match value {
+ AttrValue::Literal(Lit::Bool(b)) => Ok(quote! { #b }),
+ AttrValue::Literal(other) => Err(syn::Error::new(
+ other.span(),
+ "expected `true` or `false` here",
+ )),
+ AttrValue::Expr(expr) => Ok(quote! { #expr }),
+ }
+}
+
+/// `width="240px"` becomes `Size::Px(240.0)`, not a bare number.
+///
+/// This is correctness, not convenience: `Size` derives `#[serde(untagged)]`, so
+/// `Px(240.0)` and `Percent(240.0)` both serialize to `240.0` and the widgets
+/// emit `Size::to_css()` by hand. Guessing the wrong variant silently changes
+/// the CSS that reaches the client.
+fn size_arg(value: &AttrValue) -> syn::Result {
+ let lit = match value {
+ AttrValue::Expr(expr) => return Ok(quote! { #expr }),
+ AttrValue::Literal(lit) => lit,
+ };
+
+ let text = match lit {
+ Lit::Str(s) => s.value(),
+ // `width=240` is unambiguous: pixels.
+ Lit::Int(int) => {
+ let v = int.base10_parse::()? as f64;
+ return Ok(quote! { ::rusty::shared::Size::Px(#v) });
+ }
+ Lit::Float(float) => {
+ let v = float.base10_parse::()?;
+ return Ok(quote! { ::rusty::shared::Size::Px(#v) });
+ }
+ other => {
+ return Err(syn::Error::new(
+ other.span(),
+ "expected a size such as `\"200px\"`, `\"50%\"` or `\"auto\"`",
+ ))
+ }
+ };
+
+ let span = lit.span();
+ if text == "auto" {
+ return Ok(quote! { ::rusty::shared::Size::Auto });
+ }
+ if let Some(px) = text.strip_suffix("px") {
+ if let Ok(v) = px.trim().parse::() {
+ return Ok(quote! { ::rusty::shared::Size::Px(#v) });
+ }
+ }
+ if let Some(pct) = text.strip_suffix('%') {
+ if let Ok(v) = pct.trim().parse::() {
+ return Ok(quote! { ::rusty::shared::Size::Percent(#v) });
+ }
+ }
+
+ let msg = format!("`{}` is not a size; use `200px`, `50%` or `auto`", text);
+ Err(syn::Error::new(span, msg))
+}
+
+const ALIGN_VARIANTS: [&str; 4] = ["start", "center", "end", "stretch"];
+
+const JUSTIFY_VARIANTS: [&str; 6] = [
+ "start",
+ "center",
+ "end",
+ "space-between",
+ "space-around",
+ "space-evenly",
+];
+
+const BUTTON_VARIANTS: [&str; 5] = ["primary", "secondary", "outline", "ghost", "danger"];
+
+const TEXT_VARIANTS: [&str; 10] = [
+ "block",
+ "heading1",
+ "heading2",
+ "heading3",
+ "heading4",
+ "paragraph",
+ "code",
+ "markdown",
+ "label",
+ "caption",
+];
+
+const BADGE_VARIANTS: [&str; 3] = ["default", "outline", "dot"];
+
+/// `variant` names a different enum per widget, so the element decides.
+fn variant_arg(element: &Ident, value: &AttrValue) -> syn::Result {
+ match element.to_string().as_str() {
+ "Button" => enum_arg(value, "ButtonVariant", &BUTTON_VARIANTS),
+ "TextBlock" => enum_arg(value, "TextVariant", &TEXT_VARIANTS),
+ "Badge" => enum_arg(value, "BadgeVariant", &BADGE_VARIANTS),
+ _ => Ok(str_arg(value)),
+ }
+}
+
+/// The module a variant enum lives in.
+///
+/// `widgets/mod.rs` re-exports the widget structs but not every variant enum
+/// (`TextVariant`, `ButtonVariant` and `BadgeVariant` are all reachable only
+/// through their defining module), so lowering emits the full path. The crate's
+/// contract is to call existing code, not to add re-exports to `rusty::widgets`.
+fn variant_module(enum_name: &str) -> Option<&'static str> {
+ match enum_name {
+ "ButtonVariant" => Some("button"),
+ "TextVariant" => Some("text"),
+ "BadgeVariant" => Some("badge"),
+ _ => None,
+ }
+}
+
+/// Map a kebab-or-snake spelling onto an enum variant path.
+///
+/// `justify="space-between"` and `justify="space_between"` both reach
+/// `Justify::SpaceBetween`, because the wire form is camelCase and neither
+/// spelling is more obviously right than the other in markup.
+fn enum_arg(value: &AttrValue, enum_name: &str, variants: &[&str]) -> syn::Result {
+ let lit = match value {
+ AttrValue::Expr(expr) => return Ok(quote! { #expr }),
+ AttrValue::Literal(lit) => lit,
+ };
+
+ let Lit::Str(s) = lit else {
+ let msg = format!("expected a `{}` name as a string literal", enum_name);
+ return Err(syn::Error::new(lit.span(), msg));
+ };
+
+ let text = s.value();
+ let normalized = text.replace('_', "-").to_lowercase();
+
+ let Some(matched) = variants.iter().find(|v| **v == normalized) else {
+ let msg = format!(
+ "unknown {} `{}`; expected one of {}",
+ enum_name,
+ text,
+ variants.join(", ")
+ );
+ return Err(syn::Error::new(s.span(), msg));
+ };
+
+ let span = s.span();
+ let enum_ident = Ident::new(enum_name, span);
+ let variant = Ident::new(&to_pascal_case(matched), span);
+ let path = enum_path(enum_name, span);
+ Ok(quote! { #path::#enum_ident::#variant })
+}
+
+/// `Align`/`Justify` are re-exported from `shared`; the per-widget variant enums
+/// are not re-exported from `widgets`, so they need their defining module.
+fn enum_path(enum_name: &str, span: Span) -> TokenStream {
+ match variant_module(enum_name) {
+ Some(module) => {
+ let module = Ident::new(module, span);
+ quote! { ::rusty::widgets::#module }
+ }
+ None => quote! { ::rusty::shared },
+ }
+}
+
+fn to_pascal_case(kebab: &str) -> String {
+ kebab
+ .split('-')
+ .map(|part| {
+ let mut chars = part.chars();
+ match chars.next() {
+ Some(first) => first.to_uppercase().collect::() + chars.as_str(),
+ None => String::new(),
+ }
+ })
+ .collect()
+}
+
+/// Read a required string-literal attribute, e.g. `direction`.
+fn string_literal(attr: &Attribute, name: &str) -> syn::Result {
+ match &attr.value {
+ AttrValue::Literal(Lit::Str(s)) => Ok(s.value()),
+ other => {
+ let msg = format!("`{}` must be a string literal", name);
+ Err(syn::Error::new(other.span(), msg))
+ }
+ }
+}
+
+/// Wrap a lowered root so the macro is usable directly as a `build()` return value.
+pub fn root_tokens(root: &ElementNode) -> syn::Result {
+ let inner = element_tokens(root)?;
+ Ok(quote! { ::rusty::views::Element::from(#inner) })
+}
diff --git a/rusty-ivyml/src/lib.rs b/rusty-ivyml/src/lib.rs
new file mode 100644
index 0000000..722f016
--- /dev/null
+++ b/rusty-ivyml/src/lib.rs
@@ -0,0 +1,119 @@
+//! Compiles `.ivyml` declarative markup into Rusty widget trees.
+//!
+//! Two function-like proc macros, both re-exported from `rusty`:
+//!
+//! - [`ivyml!`] — inline markup.
+//! - [`ivyml_file!`] — an external `.ivyml` file, resolved relative to
+//! `CARGO_MANIFEST_DIR`.
+//!
+//! Both lower to the ordinary builder chains in `rusty::widgets`, so there is no
+//! new runtime, no interpreter and no wire-format change. A malformed tag is a
+//! `rustc` error with a span, not a panic in production.
+//!
+//! ```ignore
+//! use rusty::prelude::*;
+//! use rusty::ivyml;
+//!
+//! impl View for Counter {
+//! fn build(&self, ctx: &mut BuildContext) -> Element {
+//! let count = use_state(ctx, || 0i32);
+//! ivyml! {
+//!
+//!
+//!
+//!
+//!
+//! }
+//! }
+//! }
+//! ```
+
+mod ast;
+mod codegen;
+
+use proc_macro::TokenStream;
+use quote::quote;
+use syn::parse_macro_input;
+
+/// Compile inline IvyML markup into a `rusty::views::Element`.
+///
+/// One root element per invocation. Attributes are `name=literal` or
+/// `name={rust_expr}`; children are nested elements or `{expr}` splices. Both
+/// ` ` and ` ` parse.
+#[proc_macro]
+pub fn ivyml(input: TokenStream) -> TokenStream {
+ let markup = parse_macro_input!(input as ast::Markup);
+ match codegen::root_tokens(&markup.root) {
+ Ok(tokens) => tokens.into(),
+ Err(err) => err.to_compile_error().into(),
+ }
+}
+
+/// Compile an external `.ivyml` file into a `rusty::views::Element`.
+///
+/// The path is resolved against `CARGO_MANIFEST_DIR`, so it is relative to the
+/// crate root rather than to the source file.
+///
+/// ```ignore
+/// ivyml_file!("src/views/dashboard.ivyml")
+/// ```
+#[proc_macro]
+pub fn ivyml_file(input: TokenStream) -> TokenStream {
+ let lit = parse_macro_input!(input as syn::LitStr);
+ let rel_path = lit.value();
+
+ let manifest_dir = match std::env::var("CARGO_MANIFEST_DIR") {
+ Ok(dir) => dir,
+ Err(_) => {
+ return syn::Error::new(
+ lit.span(),
+ "CARGO_MANIFEST_DIR is not set; ivyml_file! must be expanded by cargo",
+ )
+ .to_compile_error()
+ .into()
+ }
+ };
+
+ let full_path = std::path::Path::new(&manifest_dir).join(&rel_path);
+ let text = match std::fs::read_to_string(&full_path) {
+ Ok(text) => text,
+ Err(err) => {
+ let msg = format!("cannot read `{}`: {}", full_path.display(), err);
+ return syn::Error::new(lit.span(), msg).to_compile_error().into();
+ }
+ };
+
+ // Lexing the file text reaches the same parser as the inline form, which is
+ // what gives `.ivyml` files `{expr}` interpolation. The cost is that they
+ // must be Rust-lexable: no bare prose in child position.
+ let stream: proc_macro2::TokenStream = match text.parse() {
+ Ok(stream) => stream,
+ Err(err) => {
+ let msg = format!("`{}` is not lexable as Rust tokens: {}", rel_path, err);
+ return syn::Error::new(lit.span(), msg).to_compile_error().into();
+ }
+ };
+
+ let markup = match syn::parse2::(stream) {
+ Ok(markup) => markup,
+ Err(err) => return err.to_compile_error().into(),
+ };
+
+ let tokens = match codegen::root_tokens(&markup.root) {
+ Ok(tokens) => tokens,
+ Err(err) => return err.to_compile_error().into(),
+ };
+
+ let path_str = full_path.to_string_lossy().to_string();
+
+ quote! {{
+ // Rebuild when the markup changes, not just when the .rs file does. A
+ // proc macro that reads a file has no dependency edge to it, and
+ // `cargo:rerun-if-changed` is a build-script mechanism unavailable here,
+ // so without this line cargo serves a stale expansion: editing the
+ // .ivyml and rebuilding prints the old text. Do not remove.
+ const _: &str = include_str!(#path_str);
+ #tokens
+ }}
+ .into()
+}
diff --git a/rusty-macros/src/lib.rs b/rusty-macros/src/lib.rs
index a8a8109..ca4c4be 100644
--- a/rusty-macros/src/lib.rs
+++ b/rusty-macros/src/lib.rs
@@ -5,7 +5,6 @@ use syn::{parse_macro_input, Attribute, DeriveInput, Field, Ident, ItemImpl};
mod hook_rules;
mod widget_checks;
-
/// Derive macro for the `WidgetData` trait.
///
/// Generates `widget_type()`, `to_json()`, `clone_box()`, `assign_id()`,
@@ -112,21 +111,17 @@ fn expand_widget(input: &DeriveInput) -> syn::Result {
.filter(|f| f.attrs.iter().any(|a| a.path().is_ident("prop")))
.collect();
- let event_specs: Vec = match fields
+ let event_specs: Vec = fields
.iter()
.filter(|f| has_attr(f, "event"))
.map(EventSpec::parse)
- .collect()
- {
- Ok(specs) => specs,
- Err(err) => return Err(err),
- };
+ .collect::>()?;
let has_id_field = fields
.iter()
.any(|f| f.ident.as_ref().is_some_and(|i| i == "id"));
- let json_fields: Vec<_> = match prop_fields
+ let json_fields: Vec<_> = prop_fields
.iter()
.map(|f| {
let field_name = f.ident.as_ref().unwrap();
@@ -139,11 +134,7 @@ fn expand_widget(input: &DeriveInput) -> syn::Result {
map.insert(#json_key.to_string(), serde_json::to_value(#value).unwrap_or_default());
})
})
- .collect::>>()
- {
- Ok(fields) => fields,
- Err(err) => return Err(err),
- };
+ .collect::>>()?;
// Generate "has" boolean entries for event fields
let event_has_fields: Vec<_> = event_specs
diff --git a/rusty/Cargo.toml b/rusty/Cargo.toml
index 43f7531..2f94d45 100644
--- a/rusty/Cargo.toml
+++ b/rusty/Cargo.toml
@@ -16,6 +16,7 @@ tracing.workspace = true
tower-http.workspace = true
bytes.workspace = true
rusty-macros = { path = "../rusty-macros" }
+rusty-ivyml = { path = "../rusty-ivyml" }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }
diff --git a/rusty/src/lib.rs b/rusty/src/lib.rs
index 30a4ff9..1cad54c 100644
--- a/rusty/src/lib.rs
+++ b/rusty/src/lib.rs
@@ -37,3 +37,7 @@ pub use rusty_macros::Widget;
/// `State::update` during build, and never alters the code it annotates. See
/// [`rusty_macros::view`] for the rules and the `allow(..)` escape hatch.
pub use rusty_macros::view;
+
+// Re-export the markup macros. Deliberately not in `prelude`: a glob-imported
+// `ivyml!` reads as a locally defined macro, and the prelude exports only types.
+pub use rusty_ivyml::{ivyml, ivyml_file};
diff --git a/rusty/src/shared/widget_names.rs b/rusty/src/shared/widget_names.rs
index ba550e7..bf59b3b 100644
--- a/rusty/src/shared/widget_names.rs
+++ b/rusty/src/shared/widget_names.rs
@@ -9,8 +9,10 @@
//! [`crate::shared::ivy_node`], which builds on it to reshape a whole widget tree.
//!
//! All 38 Rusty widget types have an entry. `every_widget_type_is_mapped` derives its
-//! list by scanning `rusty/src/widgets/*.rs` for `"type": "..."` literals, so a widget
-//! added without an entry here fails the test rather than being silently unmapped.
+//! list by scanning `rusty/src/widgets/*.rs` for both ways a widget declares its wire
+//! name -- the `"type": "..."` literal of a hand-written `to_json` and the name
+//! `#[derive(Widget)]` generates -- so a widget added without an entry here fails the
+//! test rather than being silently unmapped.
use serde_json::Value;
@@ -197,14 +199,17 @@ mod tests {
use std::fs;
use std::path::Path;
- /// Collects every widget type name from the source of truth: the `"type": "..."`
- /// literals in `rusty/src/widgets/*.rs`.
+ /// Collects every widget type name from the source of truth: `rusty/src/widgets/*.rs`.
///
/// The previous version of `every_widget_type_is_mapped` restated the inventory as a
/// hardcoded 21-element `Vec` of constructors. When Plan 00037 took the widget count
/// to 38, the test stayed green while 17 types went unmapped -- an "exhaustiveness"
/// assertion that could not see the thing it was meant to guard. Deriving the list
/// means a new widget cannot be added without either mapping it or failing here.
+ ///
+ /// A widget declares its wire name in one of two ways, and both must be scanned:
+ /// a hand-written `to_json` spells the literal out, while `#[derive(Widget)]`
+ /// generates it and so leaves no literal behind.
fn widget_types_from_sources() -> Vec {
let widgets_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/widgets");
let mut types = Vec::new();
@@ -215,29 +220,112 @@ mod tests {
continue;
}
let source = fs::read_to_string(&path).expect("widget source should be readable");
- for line in source.lines() {
- // Match the `"type": "snake_case"` form used in every to_json body.
- let Some(rest) = line.split_once("\"type\": \"") else {
+ for name in literal_types(&source)
+ .into_iter()
+ .chain(derived_types(&source))
+ {
+ if !types.contains(&name) {
+ types.push(name);
+ }
+ }
+ }
+
+ types.sort();
+ types
+ }
+
+ /// The `"type": "snake_case"` literals a hand-written `to_json` spells out.
+ fn literal_types(source: &str) -> Vec {
+ let mut types = Vec::new();
+ for line in source.lines() {
+ let Some(rest) = line.split_once("\"type\": \"") else {
+ continue;
+ };
+ let Some((name, _)) = rest.1.split_once('"') else {
+ continue;
+ };
+ // to_json bodies use only lowercase + underscore; anything else is
+ // interpolation or a test fixture, not a real wire type name.
+ if !name.is_empty() && name.chars().all(|c| c.is_ascii_lowercase() || c == '_') {
+ types.push(name.to_string());
+ }
+ }
+ types
+ }
+
+ /// The names `#[derive(Widget)]` generates, which appear nowhere in the source
+ /// it is applied to.
+ ///
+ /// Plan 00093 moved 12 widgets onto the derive and the literal scan went blind to
+ /// every one of them -- `button` included -- so the count fell to 26 and both
+ /// assertions below started failing. The derive takes the name from
+ /// `#[widget(type = "...")]` when present and from the struct name otherwise.
+ fn derived_types(source: &str) -> Vec {
+ let mut types = Vec::new();
+ let mut lines = source.lines();
+
+ while let Some(line) = lines.next() {
+ if !derives_widget(line) {
+ continue;
+ }
+ // Walk the attributes between the derive and the struct it sits on.
+ let mut explicit: Option = None;
+ for next in lines.by_ref() {
+ let next = next.trim();
+ if let Some(rest) = next.strip_prefix("#[widget(type = \"") {
+ explicit = rest.split_once('"').map(|(name, _)| name.to_string());
continue;
- };
- let Some((name, _)) = rest.1.split_once('"') else {
+ }
+ if next.starts_with("#[") || next.starts_with("///") {
continue;
- };
- // to_json bodies use only lowercase + underscore; anything else is
- // interpolation or a test fixture, not a real wire type name.
- if !name.is_empty()
- && name.chars().all(|c| c.is_ascii_lowercase() || c == '_')
- && !types.contains(&name.to_string())
- {
- types.push(name.to_string());
}
+ if let Some(name) = struct_name(next) {
+ types.push(explicit.take().unwrap_or_else(|| to_snake_case(&name)));
+ }
+ break;
}
}
- types.sort();
types
}
+ /// Whether a line is a `#[derive(..)]` naming `Widget` itself -- not `WidgetData`,
+ /// and not some type whose name merely ends in `Widget`.
+ fn derives_widget(line: &str) -> bool {
+ let Some(rest) = line.trim().strip_prefix("#[derive(") else {
+ return false;
+ };
+ let Some((list, _)) = rest.split_once(")]") else {
+ return false;
+ };
+ list.split(',').any(|item| item.trim() == "Widget")
+ }
+
+ /// `pub struct Button {` -> `Button`.
+ fn struct_name(line: &str) -> Option {
+ let rest = line
+ .strip_prefix("pub struct ")
+ .or_else(|| line.strip_prefix("struct "))?;
+ let name: String = rest
+ .chars()
+ .take_while(|c| c.is_alphanumeric() || *c == '_')
+ .collect();
+ (!name.is_empty()).then_some(name)
+ }
+
+ /// The derive's own struct-name-to-wire-name rule, mirrored from
+ /// `rusty_macros`' `to_snake_case` so the two cannot disagree silently.
+ fn to_snake_case(name: &str) -> String {
+ let mut out = String::new();
+ for (i, ch) in name.chars().enumerate() {
+ if ch.is_uppercase() && i > 0 {
+ out.push('_');
+ }
+ out.extend(ch.to_lowercase());
+ }
+ out
+ }
+
#[test]
fn every_widget_type_is_mapped() {
let types = widget_types_from_sources();
@@ -248,7 +336,8 @@ mod tests {
assert!(
types.len() >= 38,
"expected at least 38 widget types scanned from rusty/src/widgets, found {}: {:?}. \
- If the to_json `\"type\": \"...\"` convention changed, fix this scan.",
+ If the way a widget declares its wire name changed -- a renamed to_json \
+ literal, a new alternative to #[derive(Widget)] -- fix this scan.",
types.len(),
types
);
@@ -270,9 +359,22 @@ mod tests {
#[test]
fn widget_type_scan_finds_known_widgets() {
// Negative control for the scan above: prove it actually reads the sources
- // rather than returning a list that happens to be long enough.
+ // rather than returning a list that happens to be long enough. The names are
+ // split by declaration style on purpose -- a scan that lost either branch
+ // would still find the other and the count alone might stay over 38.
let types = widget_types_from_sources();
- for expected in ["button", "text_area", "slider", "list_item", "expandable"] {
+ for expected in [
+ // hand-written `"type": "..."` literals
+ "text_area",
+ "slider",
+ "progress",
+ // generated by #[derive(Widget)] from the struct name
+ "button",
+ "list_item",
+ "expandable",
+ // generated by #[derive(Widget)] from a #[widget(type = "...")] override
+ "icon",
+ ] {
assert!(
types.contains(&expected.to_string()),
"scan missed '{}'; found {:?}",
@@ -283,6 +385,33 @@ mod tests {
assert!(!types.contains(&"not_a_widget".to_string()));
}
+ #[test]
+ fn derived_type_scan_reads_both_the_struct_name_and_the_override() {
+ // Unit-test the derive branch against fixtures rather than the live tree, so
+ // a future widget migration cannot quietly make this control vacuous.
+ let source = r#"
+#[derive(Debug, Clone, Serialize, Widget)]
+pub struct TextBlock {
+ id: Option,
+}
+
+/// A doc comment and an unrelated attribute between derive and struct.
+#[derive(Clone, Widget)]
+#[widget(type = "icon")]
+#[serde(rename_all = "camelCase")]
+pub struct IconWidget {}
+
+#[derive(Clone, Serialize)]
+pub struct NotAWidget {}
+
+#[derive(Clone, WidgetData)]
+pub struct AlsoNotAWidget {}
+"#;
+
+ assert_eq!(derived_types(source), vec!["text_block", "icon"]);
+ assert!(literal_types(source).is_empty());
+ }
+
#[test]
fn constructed_widgets_all_resolve() {
// Complements the source scan by going through the real builders, so a type
diff --git a/rusty/tests/ivyml.rs b/rusty/tests/ivyml.rs
new file mode 100644
index 0000000..fc90ce0
--- /dev/null
+++ b/rusty/tests/ivyml.rs
@@ -0,0 +1,253 @@
+//! Tests for the `ivyml!` markup macro.
+//!
+//! These 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.
+//!
+//! The first and last tests are *equivalence* tests rather than shape tests:
+//! they compare the markup's serialized output against the hand-written builder
+//! chain a reviewer already trusts. If lowering drifts, the JSON stops matching.
+
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::Arc;
+
+use rusty::hooks::hook_store::HookStore;
+use rusty::ivyml;
+use rusty::shared::{Justify, Size};
+use rusty::views::view::{BuildContext, Element};
+use rusty::widgets::text::TextVariant;
+use rusty::widgets::{Card, Layout, List, ListItem, TextBlock};
+
+/// Serialize an element tree the way the server sends it to the client.
+fn json(element: &Element) -> serde_json::Value {
+ serde_json::to_value(element).expect("element serializes")
+}
+
+/// Assign widget IDs so `id` fields are populated and handlers registered.
+fn assign_ids(element: &mut Element) -> rusty::core::event_registry::EventRegistry {
+ let mut store = HookStore::default();
+ let mut ctx = BuildContext::new(&mut store, None);
+ element.assign_ids(&mut ctx);
+ ctx.take_event_registry()
+}
+
+#[test]
+fn markup_matches_the_equivalent_builder_chain() {
+ let from_markup: Element = ivyml! {
+
+
+
+
+ };
+
+ let from_builder: Element = Layout::vertical()
+ .gap(16.0)
+ .padding(24.0)
+ .child(TextBlock::new("Hello, World!").variant(TextVariant::Heading1))
+ .child(
+ TextBlock::new("This is a Rusty-Framework application.")
+ .variant(TextVariant::Paragraph),
+ )
+ .into();
+
+ assert_eq!(json(&from_markup), json(&from_builder));
+}
+
+#[test]
+fn nested_containers_keep_their_child_order() {
+ let element: Element = ivyml! {
+
+
+
+
+
+
+
+
+ };
+
+ let value = json(&element);
+ let children = &value["children"];
+ assert_eq!(children[0]["content"], "first");
+ assert_eq!(children[1]["type"], "card");
+ assert_eq!(children[1]["children"][0]["content"], "inner-a");
+ assert_eq!(children[1]["children"][1]["content"], "inner-b");
+ assert_eq!(children[2]["content"], "last");
+}
+
+#[test]
+fn list_children_use_the_item_builder_not_child() {
+ // `List` stores `items` and has no `.child` method at all, so this is the
+ // test that pins `child_method` as per-element rather than always `.child`.
+ let element: Element = ivyml! {
+
+
+
+
+ };
+
+ let value = json(&element);
+ assert_eq!(value["type"], "list");
+ assert!(
+ value.get("children").is_none(),
+ "list has items, not children"
+ );
+ assert_eq!(value["items"][0]["title"], "one");
+ assert_eq!(value["items"][1]["title"], "two");
+ assert_eq!(value["items"][1]["subtitle"], "second");
+
+ let from_builder: Element = List::new()
+ .item(ListItem::new("one"))
+ .item(ListItem::new("two").subtitle("second"))
+ .into();
+ assert_eq!(value, json(&from_builder));
+}
+
+#[test]
+fn size_literals_reach_the_wire_as_css_not_bare_numbers() {
+ // `Size` derives `#[serde(untagged)]`, so `Px(240.0)` and `Percent(240.0)`
+ // both serialize to a bare `240.0`; the widgets emit `to_css()` by hand.
+ // Asserting the CSS strings is what proves the right variant was chosen.
+ let element: Element = ivyml! {
+
+ };
+
+ let value = json(&element);
+ assert_eq!(value["width"], "100%");
+ assert_eq!(value["height"], "240px");
+
+ let from_builder: Element = Layout::horizontal()
+ .width(Size::Percent(100.0))
+ .height(Size::Px(240.0))
+ .into();
+ assert_eq!(value, json(&from_builder));
+
+ // `auto` is the third variant, and it serializes to `null` without `to_css`.
+ let auto: Element = ivyml! { };
+ assert_eq!(json(&auto)["width"], "auto");
+}
+
+#[test]
+fn grid_direction_passes_columns_to_the_constructor() {
+ let element: Element = ivyml! {
+
+
+
+ };
+
+ let value = json(&element);
+ assert_eq!(value["direction"], "grid");
+ assert_eq!(value["columns"], 3);
+ assert_eq!(value["gap"], 8.0);
+
+ let from_builder: Element = Layout::grid(3)
+ .gap(8.0)
+ .child(TextBlock::new("cell"))
+ .into();
+ assert_eq!(value, json(&from_builder));
+}
+
+#[test]
+fn interpolated_string_expressions_coerce_into_str_slots() {
+ // Most Rusty constructors take `&str`, and the common case in a real view is
+ // a `format!`, which yields `String`. Lowering emits `&(expr)` so deref
+ // coercion covers `String`, `&String`, `&str` and `format!(..)` alike.
+ let n = 7;
+ let owned: String = "owned".to_string();
+ let borrowed: &String = &owned;
+ let slice: &str = "slice";
+
+ let element: Element = ivyml! {
+
+
+
+
+
+
+ };
+
+ let children = &json(&element)["children"];
+ assert_eq!(children[0]["content"], "count = 7");
+ assert_eq!(children[1]["content"], "owned");
+ assert_eq!(children[2]["content"], "owned");
+ assert_eq!(children[3]["content"], "slice");
+}
+
+#[test]
+fn interpolated_element_expressions_splice_into_child_position() {
+ let existing: Element = TextBlock::new("spliced").into();
+ let widget = Card::new().child(TextBlock::new("from-builder"));
+
+ let element: Element = ivyml! {
+
+
+ {existing}
+ {widget}
+
+ };
+
+ let children = &json(&element)["children"];
+ assert_eq!(children[0]["content"], "literal");
+ assert_eq!(children[1]["content"], "spliced");
+ assert_eq!(children[2]["type"], "card");
+ assert_eq!(children[2]["children"][0]["content"], "from-builder");
+}
+
+#[test]
+fn handlers_register_and_dispatch_after_assign_ids() {
+ // The test that pins `on_*` as its own argument class. With handlers falling
+ // through to the `&str` default the closure is passed as `&(..)`, which is a
+ // borrowed temporary and cannot satisfy `on_click`'s `'static` bound (E0716)
+ // — so this would not compile at all rather than fail an assertion.
+ let hits = Arc::new(AtomicUsize::new(0));
+ let hits_clone = hits.clone();
+
+ let mut element: Element = ivyml! {
+
+ };
+
+ let registry = assign_ids(&mut element);
+ assert_eq!(json(&element)["hasOnClick"], true);
+ assert!(registry.dispatch("w-0", "click", serde_json::Value::Null));
+ assert_eq!(hits.load(Ordering::SeqCst), 1);
+}
+
+#[test]
+fn enum_valued_attributes_accept_kebab_and_snake_spelling() {
+ let kebab: Element = ivyml! {
+
+ };
+ let snake: Element = ivyml! {
+
+ };
+
+ let value = json(&kebab);
+ assert_eq!(value["justify"], "spaceBetween");
+ assert_eq!(value["align"], "center");
+ assert_eq!(value, json(&snake));
+
+ let from_builder: Element = Layout::horizontal()
+ .justify(Justify::SpaceBetween)
+ .align(rusty::shared::Align::Center)
+ .into();
+ assert_eq!(value, json(&from_builder));
+
+ // Per-widget `variant` enums resolve against the element, not one shared enum.
+ let button: Element = ivyml! { };
+ assert_eq!(json(&button)["variant"], "ghost");
+ let text: Element = ivyml! { };
+ assert_eq!(json(&text)["variant"], "heading1");
+}
+
+#[test]
+fn self_closing_and_paired_forms_are_equivalent() {
+ let self_closing: Element = ivyml! { };
+ let paired: Element = ivyml! { };
+ assert_eq!(json(&self_closing), json(&paired));
+ assert_eq!(json(&self_closing), json(&Card::new().into()));
+
+ // And the same for an element that does carry attributes.
+ let a: Element = ivyml! { };
+ let b: Element = ivyml! { };
+ assert_eq!(json(&a), json(&b));
+}