Skip to main content

winged_rust/
macros.rs

1//! The [`html!`](crate::html) declarative macro.
2//!
3//! Rust's answer to Winged-Swift's `@HTMLBuilder` / `@HTMLFragmentBuilder` result builders
4//! (`Sources/WingedSwift/functions/HTMLFunctions.swift`). It is **sugar over the builder
5//! API, never a second code path** — every form here expands to the same `Element` and
6//! `Node` calls you would write by hand, so there is exactly one renderer to keep correct.
7
8use crate::core::{Element, Node};
9
10/// Builds a node tree from nested markup-like syntax.
11///
12/// # Syntax
13///
14/// | form | meaning |
15/// | --- | --- |
16/// | `div { … }` | an element with children |
17/// | `div(class = "card", id = "x") { … }` | attributes, then children |
18/// | `input(type = "email", required)` | a bare name is a boolean attribute |
19/// | `"text"` | escaped text |
20/// | `(expr)` | an escaped `Display` expression |
21/// | `raw(expr)` | unescaped markup |
22/// | `@if cond { … } @else { … }` | conditional, empty branch renders to nothing |
23/// | `@for pat in iter { … }` | repetition |
24///
25/// # Examples
26///
27/// ```
28/// use winged_rust::{html, prelude::*};
29///
30/// let page = html! {
31///     div(class = "card", id = "hero") {
32///         h1 { "Welcome" }
33///         p { "Fuel, tyres & chain." }
34///     }
35/// };
36///
37/// assert_eq!(
38///     page.render(),
39///     r#"<div class="card" id="hero"><h1>Welcome</h1><p>Fuel, tyres &amp; chain.</p></div>"#
40/// );
41/// ```
42///
43/// Conditionals and loops, matching `buildOptional` / `buildEither` / `buildArray`:
44///
45/// ```
46/// use winged_rust::{html, prelude::*};
47///
48/// let names = ["Ana", "Bruno"];
49/// let logged_in = false;
50///
51/// let list = html! {
52///     ul {
53///         @for name in names { li { (name) } }
54///         @if logged_in { li { "Sign out" } }
55///     }
56/// };
57///
58/// assert_eq!(list.render(), "<ul><li>Ana</li><li>Bruno</li></ul>");
59/// ```
60///
61/// The expansion is the builder API, so the two are interchangeable:
62///
63/// ```
64/// use winged_rust::{html, prelude::*};
65/// assert_eq!(
66///     html! { p(class = "lead") { "hi" } }.render(),
67///     Node::from(p().add_class("lead").child(Node::text("hi"))).render()
68/// );
69/// ```
70#[macro_export]
71macro_rules! html {
72    // A single root node.
73    ($($body:tt)*) => {{
74        let mut __nodes = $crate::macros::node_buffer();
75        $crate::html_nodes!(__nodes, $($body)*);
76        if __nodes.len() == 1 {
77            __nodes.pop().unwrap_or_else(|| $crate::Node::Fragment(::std::vec::Vec::new()))
78        } else {
79            $crate::Node::Fragment(__nodes)
80        }
81    }};
82}
83
84/// Accumulates sibling nodes into a `Vec`. Not part of the public API.
85#[doc(hidden)]
86#[macro_export]
87macro_rules! html_nodes {
88    // Terminal.
89    ($out:ident,) => {};
90
91    // @if and @for hand off to a token muncher. A `$cond:expr` fragment cannot be
92    // used before a brace: the expression parser would read `show { … }` as a struct
93    // literal and macro_rules cannot backtrack once a fragment has been consumed.
94    ($out:ident, @if $($tail:tt)*) => { $crate::html_if!($out, [] $($tail)*); };
95    ($out:ident, @for $($tail:tt)*) => { $crate::html_for!($out, [] $($tail)*); };
96
97    // raw(expr) — unescaped markup.
98    ($out:ident, raw($value:expr) $($rest:tt)*) => {
99        $out.push($crate::Node::raw(::std::string::ToString::to_string(&$value)));
100        $crate::html_nodes!($out, $($rest)*);
101    };
102
103    // Element with attributes and children.
104    ($out:ident, $tag:ident ( $($attrs:tt)* ) { $($children:tt)* } $($rest:tt)*) => {
105        {
106            let __el = $crate::elements::$tag();
107            let __el = $crate::html_attrs!(__el, $($attrs)*);
108            let mut __kids = $crate::macros::node_buffer();
109            $crate::html_nodes!(__kids, $($children)*);
110            $out.push($crate::Node::Element(
111                $crate::macros::element_with_children(__el, __kids)));
112        }
113        $crate::html_nodes!($out, $($rest)*);
114    };
115
116    // Element with attributes only.
117    ($out:ident, $tag:ident ( $($attrs:tt)* ) $($rest:tt)*) => {
118        {
119            let __el = $crate::elements::$tag();
120            $out.push($crate::Node::Element($crate::html_attrs!(__el, $($attrs)*)));
121        }
122        $crate::html_nodes!($out, $($rest)*);
123    };
124
125    // Element with children only.
126    ($out:ident, $tag:ident { $($children:tt)* } $($rest:tt)*) => {
127        {
128            let mut __kids = $crate::macros::node_buffer();
129            $crate::html_nodes!(__kids, $($children)*);
130            $out.push($crate::Node::Element(
131                $crate::macros::element_with_children($crate::elements::$tag(), __kids)));
132        }
133        $crate::html_nodes!($out, $($rest)*);
134    };
135
136    // Interpolated expression, escaped.
137    ($out:ident, ($value:expr) $($rest:tt)*) => {
138        $out.push($crate::Node::text(::std::string::ToString::to_string(&$value)));
139        $crate::html_nodes!($out, $($rest)*);
140    };
141
142    // Literal text, escaped.
143    ($out:ident, $text:literal $($rest:tt)*) => {
144        $out.push($crate::Node::text($text));
145        $crate::html_nodes!($out, $($rest)*);
146    };
147
148    // A bare element with no attributes and no children.
149    ($out:ident, $tag:ident $($rest:tt)*) => {
150        $out.push($crate::Node::Element($crate::elements::$tag()));
151        $crate::html_nodes!($out, $($rest)*);
152    };
153}
154
155/// Applies an attribute list to an element. Not part of the public API.
156#[doc(hidden)]
157#[macro_export]
158macro_rules! html_attrs {
159    ($el:expr,) => { $el };
160    ($el:expr) => { $el };
161
162    // "key" = value — a string key, for names that are Rust keywords such as `type`.
163    ($el:expr, $key:literal = $value:expr, $($rest:tt)*) => {
164        $crate::html_attrs!(
165            $el.attr($key, ::std::string::ToString::to_string(&$value)), $($rest)*)
166    };
167    ($el:expr, $key:literal = $value:expr) => {
168        $el.attr($key, ::std::string::ToString::to_string(&$value))
169    };
170
171    // key = value
172    ($el:expr, $key:ident = $value:expr, $($rest:tt)*) => {
173        $crate::html_attrs!(
174            $el.attr(stringify!($key), ::std::string::ToString::to_string(&$value)), $($rest)*)
175    };
176    ($el:expr, $key:ident = $value:expr) => {
177        $el.attr(stringify!($key), ::std::string::ToString::to_string(&$value))
178    };
179
180    // A bare name is a boolean attribute.
181    ($el:expr, $key:literal, $($rest:tt)*) => {
182        $crate::html_attrs!($el.bool_attr($key), $($rest)*)
183    };
184    ($el:expr, $key:literal) => {
185        $el.bool_attr($key)
186    };
187    ($el:expr, $key:ident, $($rest:tt)*) => {
188        $crate::html_attrs!($el.bool_attr(stringify!($key)), $($rest)*)
189    };
190    ($el:expr, $key:ident) => {
191        $el.bool_attr(stringify!($key))
192    };
193}
194
195/// An empty sibling buffer for the macro to push into.
196///
197/// A function rather than an inline `Vec::new()` so that `clippy::vec_init_then_push` does
198/// not fire inside every `html!` expansion in every downstream crate. The lint is about
199/// hand-written style; this code is generated.
200#[doc(hidden)]
201#[must_use]
202pub fn node_buffer() -> Vec<Node> {
203    Vec::new()
204}
205
206/// Attaches macro-collected children to an element.
207///
208/// When every child is text, it becomes the element's **content** rather than a list of
209/// child nodes. That is what the builder API produces for `p().text("x")`, and the two
210/// render differently once pretty printing is on: content stays on the element's line,
211/// while a child text node gets its own indented line. Collapsing here is what keeps the
212/// macro true sugar over the builder rather than a second, subtly different code path.
213#[doc(hidden)]
214#[must_use]
215pub fn element_with_children(element: Element, children: Vec<Node>) -> Element {
216    let all_text = !children.is_empty() && children.iter().all(|c| matches!(c, Node::Text(_)));
217
218    if all_text {
219        let mut content = String::new();
220        for child in children {
221            if let Node::Text(text) = child {
222                content.push_str(&text);
223            }
224        }
225        // The text was escaped when each node was built, so it goes in verbatim.
226        return element.raw_text(content);
227    }
228
229    element.children_from(children)
230}
231
232/// Token muncher for `@if`. Collects condition tokens until the body block. Not public.
233#[doc(hidden)]
234#[macro_export]
235macro_rules! html_if {
236    ($out:ident, [$($cond:tt)+] { $($body:tt)* } @else { $($alt:tt)* } $($rest:tt)*) => {
237        if $($cond)+ { $crate::html_nodes!($out, $($body)*); }
238        else { $crate::html_nodes!($out, $($alt)*); }
239        $crate::html_nodes!($out, $($rest)*);
240    };
241    ($out:ident, [$($cond:tt)+] { $($body:tt)* } $($rest:tt)*) => {
242        if $($cond)+ { $crate::html_nodes!($out, $($body)*); }
243        $crate::html_nodes!($out, $($rest)*);
244    };
245    ($out:ident, [$($cond:tt)*] $next:tt $($rest:tt)*) => {
246        $crate::html_if!($out, [$($cond)* $next] $($rest)*);
247    };
248}
249
250/// Token muncher for `@for`. Collects `pat in iter` until the body block. Not public.
251#[doc(hidden)]
252#[macro_export]
253macro_rules! html_for {
254    ($out:ident, [$($head:tt)+] { $($body:tt)* } $($rest:tt)*) => {
255        for $($head)+ { $crate::html_nodes!($out, $($body)*); }
256        $crate::html_nodes!($out, $($rest)*);
257    };
258    ($out:ident, [$($head:tt)*] $next:tt $($rest:tt)*) => {
259        $crate::html_for!($out, [$($head)* $next] $($rest)*);
260    };
261}
262
263#[cfg(test)]
264mod tests {
265    use crate::prelude::*;
266
267    #[test]
268    fn a_bare_element_renders_empty() {
269        assert_eq!(html! { div }.render(), "<div></div>");
270    }
271
272    #[test]
273    fn text_children_are_escaped() {
274        assert_eq!(html! { p { "a & b" } }.render(), "<p>a &amp; b</p>");
275    }
276
277    #[test]
278    fn raw_children_are_not_escaped() {
279        assert_eq!(html! { p { raw("<b>x</b>") } }.render(), "<p><b>x</b></p>");
280    }
281
282    #[test]
283    fn attributes_and_children_combine() {
284        assert_eq!(
285            html! { div(class = "card", id = "hero") { p { "hi" } } }.render(),
286            r#"<div class="card" id="hero"><p>hi</p></div>"#
287        );
288    }
289
290    #[test]
291    fn a_bare_attribute_name_is_a_boolean_attribute() {
292        assert_eq!(
293            html! { input("type" = "email", name = "email", required) }.render(),
294            r#"<input type="email" name="email" required>"#
295        );
296    }
297
298    #[test]
299    fn expressions_interpolate_escaped() {
300        let name = "Tom & Jerry";
301        assert_eq!(
302            html! { span { (name) } }.render(),
303            "<span>Tom &amp; Jerry</span>"
304        );
305    }
306
307    /// Matches `buildArray` in Winged-Swift's `HTMLBuilder`.
308    #[test]
309    fn for_loops_expand_to_siblings() {
310        let names = ["Ana", "Bruno"];
311        assert_eq!(
312            html! { ul { @for n in names { li { (n) } } } }.render(),
313            "<ul><li>Ana</li><li>Bruno</li></ul>"
314        );
315    }
316
317    /// Matches `buildOptional`: a false branch renders to nothing at all.
318    #[test]
319    fn a_false_condition_contributes_no_node() {
320        let show = false;
321        assert_eq!(
322            html! { div { @if show { p { "x" } } } }.render(),
323            "<div></div>"
324        );
325    }
326
327    /// Matches `buildEither`.
328    #[test]
329    fn if_else_picks_one_branch() {
330        let logged_in = true;
331        let markup = html! {
332            nav { @if logged_in { a { "Sign out" } } @else { a { "Sign in" } } }
333        };
334        assert_eq!(markup.render(), "<nav><a>Sign out</a></nav>");
335    }
336
337    #[test]
338    fn several_roots_become_a_fragment() {
339        assert_eq!(html! { p { "a" } p { "b" } }.render(), "<p>a</p><p>b</p>");
340    }
341
342    /// The macro is sugar: it must produce exactly what the builder produces.
343    #[test]
344    fn macro_output_equals_the_builder_output() {
345        let from_macro = html! { div(class = "card") { h1 { "T" } p { "B" } } };
346        let from_builder = Node::from(
347            div()
348                .add_class("card")
349                .child(h1().text("T"))
350                .child(p().text("B")),
351        );
352        assert_eq!(from_macro.render(), from_builder.render());
353        assert_eq!(from_macro.render_pretty(), from_builder.render_pretty());
354    }
355
356    #[test]
357    fn nesting_survives_pretty_printing() {
358        let markup = html! { div { section { p { "deep" } } } };
359        assert_eq!(
360            markup.render_pretty(),
361            "<div>\n  <section>\n    <p>deep</p>\n  </section>\n</div>"
362        );
363    }
364
365    // Ports `BuilderInitTests`, whose thesis is that Swift's result-builder initialiser is
366    // *exactly* equivalent to the array one. `html!` makes the same promise here — rule 9
367    // of AGENTS.md: sugar over the builder, never a second code path — so each of these
368    // asserts the macro and the builder agree, and then pins the markup.
369
370    /// Ports `BuilderInitTests.plainContainer`.
371    #[test]
372    fn the_macro_and_the_builder_agree_on_a_plain_container() {
373        let macro_built = html! { div { p { "Hi" } } };
374        let builder_built = div().child(p().text("Hi"));
375
376        assert_eq!(macro_built.render(), builder_built.render());
377        assert_eq!(macro_built.render(), "<div><p>Hi</p></div>");
378    }
379
380    /// Ports `BuilderInitTests.containerWithRequiredAttribute`.
381    #[test]
382    fn the_macro_and_the_builder_agree_on_a_required_attribute() {
383        let macro_built = html! { a(href = "/docs") { span { "Docs" } } };
384        let builder_built = link_to("/docs").child(span().text("Docs"));
385
386        assert_eq!(macro_built.render(), builder_built.render());
387        assert_eq!(
388            macro_built.render(),
389            r#"<a href="/docs"><span>Docs</span></a>"#
390        );
391    }
392
393    /// Ports `BuilderInitTests.containerWithAttributesAndContent`.
394    #[test]
395    fn attributes_and_children_render_together() {
396        assert_eq!(
397            html! { div(id = "main") { p { "Hi" } } }.render(),
398            r#"<div id="main"><p>Hi</p></div>"#
399        );
400    }
401
402    /// Ports `BuilderInitTests.booleanFlagIsPreserved`.
403    #[test]
404    fn a_boolean_flag_survives_the_macro() {
405        assert_eq!(
406            html! { details(open) { summary { "More" } } }.render(),
407            "<details open><summary>More</summary></details>"
408        );
409    }
410
411    /// Ports `BuilderInitTests.tableFamily`.
412    #[test]
413    fn the_table_family_nests() {
414        let markup = html! {
415            table {
416                thead { tr { th { "A" } } }
417                tbody { tr { td { "1" } } }
418            }
419        };
420
421        assert_eq!(
422            markup.render(),
423            "<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>"
424        );
425    }
426
427    /// Ports `BuilderInitTests.formFamily`.
428    ///
429    /// `for` is a Rust keyword, so the attribute name is quoted — the one place the macro
430    /// asks for punctuation the Swift builder does not.
431    #[test]
432    fn the_form_family_nests() {
433        let markup = html! {
434            form {
435                fieldset {
436                    legend { "Account" }
437                    label("for" = "email") { "Email" }
438                }
439            }
440        };
441
442        assert_eq!(
443            markup.render(),
444            concat!(
445                "<form><fieldset><legend>Account</legend>",
446                r#"<label for="email">Email</label></fieldset></form>"#,
447            )
448        );
449    }
450
451    /// Ports `BuilderInitTests.mediaFamily`.
452    #[test]
453    fn the_media_family_nests() {
454        let markup = html! {
455            picture {
456                source(srcset = "a.webp", "type" = "image/webp")
457                img(src = "a.jpg", alt = "A")
458            }
459        };
460
461        assert_eq!(
462            markup.render(),
463            concat!(
464                r#"<picture><source srcset="a.webp" type="image/webp">"#,
465                r#"<img src="a.jpg" alt="A"></picture>"#,
466            )
467        );
468    }
469
470    /// Ports `BuilderInitTests.loopsInsideTheBuilder`.
471    #[test]
472    fn a_loop_inside_the_macro_repeats_its_body() {
473        let markup = html! { ul { @for name in ["a", "b", "c"] { li { (name) } } } };
474
475        assert_eq!(markup.render(), "<ul><li>a</li><li>b</li><li>c</li></ul>");
476    }
477
478    /// Ports `BuilderInitTests.conditionsInsideTheBuilder`.
479    #[test]
480    fn a_false_condition_inside_the_macro_renders_nothing() {
481        let is_admin = false;
482        let markup = html! {
483            nav {
484                a(href = "/") { "Home" }
485                @if is_admin { a(href = "/admin") { "Admin" } }
486            }
487        };
488
489        assert_eq!(markup.render(), r#"<nav><a href="/">Home</a></nav>"#);
490    }
491
492    /// Ports `BuilderInitTests.mapInsideTheBuilder`.
493    ///
494    /// Swift drops a `map` straight into the builder. The macro takes an iterator through
495    /// `@for` instead, and `children_from` is the builder's spelling of the same thing —
496    /// the test is that both land on identical markup.
497    #[test]
498    fn a_mapped_sequence_matches_the_macro_loop() {
499        let macro_built = html! { ol { @for item in ["x", "y"] { li { (item) } } } };
500        let builder_built = ol().children_from(["x", "y"].map(|item| li().text(item)));
501
502        assert_eq!(macro_built.render(), builder_built.render());
503        assert_eq!(macro_built.render(), "<ol><li>x</li><li>y</li></ol>");
504    }
505
506    /// Ports `BuilderInitTests.emptyBuilderProducesAnEmptyElement`.
507    #[test]
508    fn an_empty_body_produces_an_empty_element() {
509        assert_eq!(html! { div {} }.render(), "<div></div>");
510    }
511
512    /// Ports `BuilderInitTests.untypedElementSupportsTheBuilder`.
513    ///
514    /// The macro resolves a tag name to its generated constructor, and there is no
515    /// `hgroup()` among the 93 — Winged-Swift has no `Hgroup` type either, which is why its
516    /// own test reaches for the untyped `HTMLTag`. `Element::new` is that escape hatch here,
517    /// and it composes with macro-built children.
518    #[test]
519    fn an_untyped_element_takes_macro_built_children() {
520        let group = Element::new("hgroup")
521            .child(html! { h1 { "Title" } })
522            .child(html! { p { "Subtitle" } });
523
524        assert_eq!(
525            group.render(),
526            "<hgroup><h1>Title</h1><p>Subtitle</p></hgroup>"
527        );
528    }
529
530    /// Ports `BuilderInitTests.chainingStillPreservesTheType`.
531    ///
532    /// Swift's builder initialiser returns the concrete tag type, so `.addClass` chains off
533    /// it. `html!` returns a [`Node`], which is the union of every shape a child can take
534    /// and has no builder methods — so chaining happens on the builder, and the macro
535    /// supplies the children. Both spellings render the same.
536    #[test]
537    fn chaining_happens_on_the_builder_side() {
538        let chained = div().child(html! { p { "Hi" } }).add_class("card");
539
540        assert_eq!(chained.render(), r#"<div class="card"><p>Hi</p></div>"#);
541    }
542
543    /// Ports `BuilderInitTests.nestingIsArbitrarilyDeep`.
544    #[test]
545    fn nesting_goes_as_deep_as_it_is_written() {
546        let page = html! {
547            body { main_tag { section { article { h2 { "Title" } } } } }
548        };
549
550        assert_eq!(
551            page.render(),
552            "<body><main><section><article><h2>Title</h2></article></section></main></body>"
553        );
554    }
555
556    // Ports `HTMLBuilderTests`, which exercises Swift's result-builder plumbing:
557    // `buildOptional`, `buildEither` and `buildExpression`. Those are compiler hooks with
558    // no counterpart here — `html!` lowers `@if`/`@else`/`@for` straight onto the builder —
559    // so each port keeps the *behaviour* the Swift test describes. Several of those tests
560    // reach into `document.children[0].name`; these assert markup instead, which is rule 11
561    // of AGENTS.md and the reason Winged-Swift's own suite survived its 2.0 rewrite.
562
563    /// Ports `HTMLBuilderTests.testHTMLBuilderCreatesRootHTMLTag`.
564    #[test]
565    fn a_root_html_element_keeps_its_children_in_order() {
566        let document = html_tag().child(body()).child(head());
567
568        assert_eq!(document.render(), "<html><body></body><head></head></html>");
569    }
570
571    /// Ports `HTMLBuilderTests.testHTMLBuilderHandlesOptional`.
572    ///
573    /// `Option` is an iterator of zero or one item, so `children_from` is all the plumbing
574    /// an optional child needs.
575    #[test]
576    fn an_optional_child_is_included_when_present() {
577        let present: Option<Element> = Some(Element::new("optional"));
578        let absent: Option<Element> = None;
579
580        assert_eq!(
581            html_tag().children_from(present).render(),
582            "<html><optional></optional></html>"
583        );
584        assert_eq!(html_tag().children_from(absent).render(), "<html></html>");
585    }
586
587    /// Ports `HTMLBuilderTests.testHTMLBuilderWrapsASingleNonGroupedComponent`.
588    #[test]
589    fn a_single_child_gains_no_wrapper() {
590        let document = html_tag().child(body().child(h1().text("Hi")));
591
592        assert_eq!(document.render(), "<html><body><h1>Hi</h1></body></html>");
593    }
594
595    /// Ports `HTMLBuilderTests.testHTMLBuilderTakesBothBranchesOfAnIfElse`.
596    #[test]
597    fn an_if_else_renders_whichever_branch_holds() {
598        fn page(logged_in: bool) -> Element {
599            html_tag().child(html! {
600                @if logged_in { nav { "Sign out" } } @else { nav { "Sign in" } }
601            })
602        }
603
604        assert_eq!(page(true).render(), "<html><nav>Sign out</nav></html>");
605        assert_eq!(page(false).render(), "<html><nav>Sign in</nav></html>");
606    }
607
608    /// Ports `HTMLBuilderTests.testHTMLBuilderAcceptsAnArrayExpression`.
609    #[test]
610    fn a_sequence_of_children_flattens_into_siblings() {
611        let document = html_tag().children_from(["a", "b"].map(|text| p().text(text)));
612
613        assert_eq!(document.render(), "<html><p>a</p><p>b</p></html>");
614    }
615
616    /// Ports `HTMLBuilderTests.testHTMLBuilderHandlesEitherFirst`.
617    ///
618    /// Swift names the branches with the untyped `HTMLTag("first")`; the macro resolves a
619    /// tag name to its generated constructor, so real tags stand in.
620    #[test]
621    fn the_first_branch_of_a_condition_renders_alone() {
622        let document = html_tag().child(html! {
623            @if true { header } @else { footer }
624        });
625
626        assert_eq!(document.render(), "<html><header></header></html>");
627    }
628
629    /// Ports `HTMLBuilderTests.testHTMLBuilderHandlesEitherSecond`.
630    #[test]
631    fn the_second_branch_of_a_condition_renders_alone() {
632        let document = html_tag().child(html! {
633            @if false { header } @else { footer }
634        });
635
636        assert_eq!(document.render(), "<html><footer></footer></html>");
637    }
638}